Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
An extra library of meta-process implementations not included in the standard Ergo Framework library. This library contains packages with a narrow specialization. It also includes packages with external dependencies, as Ergo Framework adheres to a "zero dependency" policy.
Building reliable concurrent and distributed systems is hard. In Go, you might start with goroutines and channels. As the system grows, you add mutexes to protect shared state. Then you need to coordinate across multiple services, so you introduce message queues or RPC. Before long, you're managing synchronization primitives, handling partial failures, and debugging race conditions that only appear under load.
Ergo Framework offers a different foundation. Think of it as making goroutines addressable and message-passing-only, then extending that model across a cluster. Processes are like goroutines - lightweight, multiplexed onto OS threads - but isolated and communicating only through messages. Each process has an identifier that works whether the process is local or on a remote node. Sending a message looks the same either way.
The actor model isn't new. Erlang proved these patterns work for systems requiring massive concurrency and high reliability. Ergo brings them to Go: no external dependencies, familiar Go idioms, and performance that doesn't sacrifice correctness for speed.
The framework consists of a few fundamental pieces that work together.
A node provides the runtime environment. It manages process lifecycles, routes messages, handles network connections, and provides services like logging and scheduled tasks. When you start a node, you get infrastructure. When you spawn a process, the node handles the mechanics.
Processes are lightweight actors. Each has a mailbox where messages queue up, priority-sorted into urgent, system, main, and log queues. The process handles messages one at a time in its own goroutine. When the mailbox empties, the goroutine sleeps. This makes processes efficient - you can have thousands without resource problems. It also makes them safe - sequential message handling means no race conditions within a process.
Supervision trees provide fault tolerance. Supervisors monitor worker processes. When a worker crashes, the supervisor restarts it according to a configured strategy. Supervisors can supervise other supervisors, creating a hierarchy. Failures are isolated to subtrees. The rest of the system continues running while the failed part recovers.
Meta processes solve a specific problem: integrating blocking I/O with the actor model. HTTP servers block waiting for requests. TCP servers block accepting connections. A meta process uses two goroutines - one runs your blocking code (like http.ListenAndServe), the other handles messages from other actors. This bridges synchronous APIs with asynchronous actor communication.
The framework treats local and remote processes identically. Send a message to a process on the same node or a process on a remote node - the code is the same. The framework handles the difference.
When you send to a remote process, the node extracts the target node from the process identifier, discovers that node's address (through static routes or a registrar), establishes a connection if needed, encodes the message, and sends it. The remote node receives it, decodes it, and delivers it to the target process's mailbox. This happens automatically. Your code just sends a message.
This transparency extends to failure detection. Use the Important delivery flag and you get the same error semantics for remote processes as for local ones. Without it, a message to a missing remote process times out (was it slow or dead?). With it, you get immediate error notification (process doesn't exist), just like local delivery. The network becomes transparent not just for success cases but for failures too.
Nodes discover each other through a registrar. By default, each node runs a minimal registrar. Nodes on the same host find each other through localhost. For remote nodes, the framework queries the registrar on the remote host. For production clusters, configure an external registrar like etcd or Saturn for centralized discovery, cluster configuration, and application deployment tracking.
You write business logic using message passing between processes. The framework handles concurrency (processes run in parallel but each is sequential internally), fault tolerance (supervisors restart failures), and distribution (messages route automatically to remote processes). You're not writing code to manage connections, encode messages, or handle network failures explicitly. Those are solved problems handled by the framework.
Systems built this way have useful properties. They scale by adding nodes and distributing processes across them. The code doesn't change - deployment topology is operational configuration. They handle failures through supervision rather than defensive programming everywhere. They evolve through composition - add new process types, adjust supervision strategies, change message flows - without restructuring the foundation.
The development experience differs from typical microservices. No REST endpoints to define. No service discovery to configure (it's built in). No serialization libraries to manage (the framework handles it). No retry logic scattered throughout (supervision handles recovery). You model your domain as processes exchanging messages, and the framework provides the infrastructure.
Lock-free queues in process mailboxes avoid contention. Processes sleep when idle, consuming no CPU. Connection pooling uses multiple TCP connections per remote node for parallel delivery. These design choices add up to performance comparable to hand-written concurrent code, but without the complexity.
The real performance benefit is development velocity. You're not debugging race conditions or deadlocks. You're not coordinating distributed transactions. You're not managing connection pools or implementing retry logic. The framework handles those concerns, leaving you to focus on what your system does.
Benchmarks measuring message passing, network communication, and serialization performance are available at .
The framework uses only the Go standard library. No external dependencies means no version conflicts, no supply chain vulnerabilities, no surprise breaking changes from third-party packages. The requirement is just Go 1.21 or higher.
This isn't ideological purity. It's practical stability. The framework's behavior depends only on Go itself. Updates are predictable. Supply chain is simple. The code you write today will compile and run the same way years from now, assuming Go maintains backward compatibility (which it does).
For detailed explanations of these concepts, start with and explore the section. For API documentation, see the godoc comments in the source code.
The Ergo Framework allows nodes to run with various network stacks. You can replace the default network stack or add it as an additional stack. For more information, refer to the section.
This library contains implementations of network stacks that are not part of the standard Ergo Framework library.
An extra library of logger implementations not included in the standard Ergo Framework library. This library contains packages with a narrow specialization. It also includes packages with external dependencies, as Ergo Framework adheres to a "zero dependency" policy.
Terminal output with ANSI colors. Highlights Ergo types (PIDs, Atoms, Refs) and colorizes log levels for visual clarity. Synchronous writes to stdout with immediate formatting.
Use cases: Local development, interactive debugging, fast visual scanning of logs in a terminal.
File logger with automatic time-based rotation and optional gzip compression. Asynchronous writes via background goroutine. Configurable retention policy.
Use cases: Production long-running services, time-windowed log archives, on-host log retention with bounded disk usage.
Forwards panics and errors to a Sentry project. Captures panic stack traces pointing at the panic origin and tags events by ergo subsystem (node, network, application, meta, process). Asynchronous, non-blocking.
Use cases: Centralized error tracking, panic alerting in production, grouping recurring failures by root cause.
The additional application library for Ergo Framework contains packages with a narrow specialization or external dependencies since Ergo Framework adheres to the "zero dependencies" principle.
You can find the source code of these applications in the application library repository at .
An extra library of registrars or client implementations not included in the standard Ergo Framework library. This library contains packages with a narrow specialization. It also includes packages with external dependencies, as Ergo Framework follows a "zero dependency" policy.
A client library for the central registrar. Provides service discovery, configuration management, and real-time cluster event notifications through a centralized registrar service.
Features:
Centralized service discovery
Real-time event notifications
An extra library of actors implementations not included in the standard Ergo Framework library. This library contains packages with a narrow specialization. It also includes packages with external dependencies, as Ergo Framework adheres to a "zero dependency" policy.
Kubernetes health probe actor that serves /health/live, /health/ready, and /health/startup endpoints. Actors register named signals with probe type and optional heartbeat timeout. When a signal goes down, the corresponding probe endpoint returns 503.
Use cases: Kubernetes liveness/readiness/startup probes, container orchestration integration, dependency health tracking, graceful degradation.
Distributed leader election actor implementing Raft-inspired consensus algorithm. Provides coordination primitives for building systems that require single leader selection across a cluster.
Use cases: Task schedulers, resource managers, single-writer databases, distributed locks, cluster coordinators.
Use cases: Production monitoring, performance analysis, capacity planning, debugging distributed systems.
Configuration management
TLS security support
Token-based authentication
A client library for etcd, a distributed key-value store. Provides decentralized service discovery, hierarchical configuration management with type conversion, and automatic lease management.
Features:
Distributed service discovery
Hierarchical configuration with type conversion from strings ("int:123", "float:3.14")
Automatic lease management and cleanup
Real-time cluster change notifications
TLS/authentication support
Four-level configuration priority system
Choose Saturn for centralized management with a dedicated registrar service, or etcd for a distributed approach with built-in consensus and reliability guarantees.
TLS Certificate Management
Network communication in production systems needs encryption. TLS provides this, but managing TLS certificates introduces operational challenges. Certificates expire. Security incidents require rotation. Updating certificates traditionally means restarting services, causing downtime.
The naive approach loads certificates at startup from files. When you need to update a certificate, you replace the file and restart the service. For a single service, this works. For distributed systems with dozens of nodes and services, coordinating restarts for certificate updates becomes an operational burden.
Ergo Framework provides gen.CertManager for live certificate updates. Load a certificate at startup, and you can update it later without restarting. All components using that certificate manager - node acceptors, web servers, TCP servers - automatically use the updated certificate for new connections.
Create a certificate manager with an initial certificate:
For development or testing, generate a self-signed certificate:
Note: Self-signed certificates require setting InsecureSkipVerify: true in network options to bypass certificate validation. This is acceptable for development but never use it in production.
The Actor Model and Its Properties
The actor model is a computational approach to building concurrent systems, first proposed in the 1970s. At its core is a simple yet powerful idea: instead of having program components share memory and coordinate through locks, they communicate by sending messages to each other.
In the actor model, everything is an actor. An actor is an independent entity that has its own private state and processes incoming messages one at a time. Actors never directly access each other's state. Instead, they send messages and wait for responses if needed.
This might seem like a constraint, but it's actually what makes the model powerful. By eliminating shared state, we eliminate entire classes of concurrency bugs that plague traditional multi-threaded programs.
An actor consists of three things:
Private State - Data that belongs exclusively to this actor. No other actor can read or modify it directly.
Behavior - The logic that determines how the actor responds to messages. This can change over time as the actor processes different messages.
Mailbox - A queue where incoming messages wait to be processed. The actor pulls messages from this queue one at a time.
Pass the certificate manager to node options:
The node's network stack uses this certificate manager for TLS connections. Acceptors use it for incoming connections. Outgoing connections use it for client certificates if needed.
Update the certificate while the node is running:
The update takes effect immediately for new connections. Existing connections continue using the old certificate until they close. This allows graceful rotation - new connections get the new certificate, old connections finish naturally.
Components using the certificate manager obtain certificates through GetCertificate or GetCertificateFunc. These methods return the current certificate, so updates automatically propagate to all users of the manager.
The typical pattern involves periodic certificate renewal. A cron job or external process watches for approaching expiration. When renewal is needed, it obtains a new certificate (from Let's Encrypt, an internal CA, or however your infrastructure manages certificates) and calls Update on the certificate manager.
The certificate manager is passive - it doesn't handle renewal itself. It provides the mechanism for live updates. Your renewal logic is external, allowing integration with whatever certificate provisioning system you use.
This separation is intentional. Certificate renewal policies vary widely. Some organizations use Let's Encrypt with automated renewal. Others use internal CAs with manual processes. Some rotate certificates on a schedule, others only when necessary. The certificate manager doesn't impose policy - it just enables live updates however you choose to implement them.
For scenarios requiring client certificate authentication, use gen.CertAuthManager. It extends CertManager with CA pool management for verifying certificates on both sides of the connection. This enables mutual TLS (mTLS) where servers verify client certificates and clients verify server certificates.
All settings support runtime updates, just like certificate rotation.
For detailed configuration and examples, see .
For complete certificate manager methods and usage, refer to the gen.CertManager interface documentation in the code.
cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
if err != nil {
panic(err)
}
certManager := gen.CreateCertManager(cert)cert, err := lib.GenerateSelfSignedCert("MyService v1.0")
if err != nil {
panic(err)
}
certManager := gen.CreateCertManager(cert)Each actor processes messages sequentially, one after another. This is not a limitation but a design choice that provides important guarantees.
Consider what happens in traditional concurrent programming: multiple threads might access the same data simultaneously. To prevent corruption, you need locks. But locks introduce their own problems - deadlocks, race conditions, and complex reasoning about what state the data is in at any given moment.
Actors sidestep this entirely. Since only one message is processed at a time, the actor's state can only be in one of a finite number of well-defined states. There are no race conditions because there's no race - only one thing happens at a time within an actor.
One of the most powerful aspects of the actor model is location transparency. When you send a message to an actor, you don't need to know whether it's running in the same process, on the same machine, or halfway around the world. The semantics are the same.
This makes distribution almost trivial. Code written for a single machine can scale to a distributed system without fundamental changes. The complexity of network communication is handled by the framework, not by your application logic.
The actor model isn't just theory. It powers real production systems handling massive scale.
Erlang pioneered the practical application of the actor model. The language and its BEAM virtual machine have been running telecommunications systems since the 1980s. Systems that need to handle millions of concurrent connections with high reliability naturally gravitate toward Erlang's actor model implementation.
Akka brought the actor model to the Java ecosystem. It's used in systems that need to process high-volume transactions, manage complex workflows, or handle real-time data streams. Companies building reactive systems often choose Akka for its proven scalability patterns.
Orleans demonstrated that the actor model works well in cloud environments. Its virtual actor pattern, where actors are automatically created and destroyed based on demand, showed how the model adapts to modern distributed computing challenges.
Go has goroutines and channels, which seem similar to actors and message passing. But there's a crucial difference: goroutines are not isolated. They can share memory, which means you still need locks and face the same concurrency challenges as traditional threading.
Ergo Framework brings actor model semantics to Go. Each process is an actor with its own goroutine and its own mailbox, and the only way in is a message. That buys you the benefits of the model - sequential logic inside an actor, concurrency between actors, natural distribution - while writing ordinary Go.
One boundary the language cannot enforce for us, and it is worth knowing from the start: memory isolation is a discipline the framework supports, not a constraint it imposes. A message between two processes on the same node is handed over as the Go value it is, with no copy and no serialization. Send a map, a slice or a pointer and both processes then hold the same memory, and Go's race detector will say so. Only a message that crosses a node boundary is encoded, and the encoding is what makes the copy.
So "no shared state" is yours to keep: send values rather than references, or treat a send as handing over ownership and stop touching what you sent. The framework ships a vet tool, , whose A1001 rule flags exactly this.
The single-goroutine-per-actor constraint might seem limiting at first. In practice, it's liberating. You write sequential code within each actor, and concurrency emerges naturally from having many actors processing messages in parallel.
Working with the actor model requires a shift in thinking. Instead of thinking about shared data structures protected by locks, you think about independent entities sending messages to each other.
A typical pattern: instead of having multiple threads access a shared cache, you have a cache actor. Want to read from the cache? Send it a message. Want to write? Send a different message. The cache actor processes these requests sequentially, so there's no possibility of corruption. No locks needed.
This pattern scales beautifully. Need more throughput? Add more cache actors, each handling a portion of the key space. Need fault tolerance? Supervise the cache actors, so they restart if they crash. Need distribution? Put cache actors on different machines. The code structure remains the same.
The actor model offers a different way to think about concurrent programming. Rather than wrestling with locks and shared memory, you design systems as independent actors exchanging messages. The constraints of the model - sequential processing, message passing only, isolated state - eliminate the complexity that makes traditional concurrent programming difficult.
Ergo Framework brings this programming model to Go. It enforces actor model principles while leveraging Go's strengths: lightweight goroutines, efficient scheduling, and a simple language. The result is a way to build concurrent and distributed systems that's both powerful and approachable.
The following chapters explore how these concepts manifest in Ergo Framework's implementation. covers the lifecycle and capabilities of actors. explains how actors are managed and how they communicate across networks.
options.CertManager = certManager
node, err := ergo.StartNode("node@localhost", options)newCert, err := tls.LoadX509KeyPair("new_cert.pem", "new_key.pem")
if err != nil {
return err
}
certManager.Update(newCert)certManager := gen.CreateCertAuthManager(cert)
certManager.SetClientCAs(clientCAPool) // server verifies clients
certManager.SetRootCAs(serverCAPool) // client verifies servers
certManager.SetClientAuth(tls.RequireAndVerifyClientCert)Linking and Monitoring Mechanisms
Building reliable systems from independent processes requires solving a fundamental coordination problem. When a process terminates - whether from a crash, graceful shutdown, or network failure - other processes that depend on it or supervise it need to know. Without this knowledge, a supervisor can't restart failed workers, dependent processes continue attempting to use unavailable services, and the system degrades silently.
The challenge is detecting termination without breaking isolation. Processes can't share memory or directly observe each other's state. The traditional approach in distributed systems uses heartbeats: processes periodically signal they're alive, and silence implies failure. But heartbeats introduce overhead, timing sensitivity, and the fundamental ambiguity of distinguishing "slow" from "dead."
Ergo Framework provides a different mechanism. Processes explicitly declare relationships - links and monitors - and the framework delivers termination notifications through these channels. When a process terminates, the node automatically notifies all processes that established relationships with it. The notification is immediate, deterministic, and part of the normal message flow.
Links and monitors both deliver termination notifications, but they differ in what happens next. A link couples your lifecycle to the target's - when it terminates, you terminate. A monitor simply informs you of termination, leaving the response up to you. The choice depends on whether you need failure propagation or just failure awareness.
Creating a link to another process declares a dependency. You're stating that your operation depends on the target's continued existence. When the target terminates, you receive an exit signal - a high-priority message that typically causes your termination as well.
Exit signals arrive in the Urgent queue, bypassing normal message ordering. The default behavior is immediate termination when an exit signal arrives. This cascading failure makes sense in many scenarios. If a worker's connection to a critical service is gone, the worker has nothing useful to do and should terminate cleanly.
But sometimes you want to handle exit signals explicitly. Actors can enable exit signal trapping through act.Actor. When trapping is enabled, exit signals are delivered as gen.MessageExit* messages to your HandleMessage callback. You can examine the signal, check the termination reason, and decide whether to terminate or attempt recovery.
The process-level exit messages carry the termination reason in a Reason field. The reason tells you what happened: normal shutdown (gen.TerminateReasonNormal), abnormal crash, panic (gen.TerminateReasonPanic) or forced kill (gen.TerminateReasonKill). This context lets you make informed decisions about how to react.
gen.MessageExitNode is the exception: it carries only Name, the node that went away. A lost connection has no reason to report beyond itself.
The framework provides linking methods for different identification schemes. LinkPID takes a process identifier and links to that specific process instance. When it terminates, you receive gen.MessageExitPID. LinkProcessID links to a registered name rather than a specific instance. If the process terminates or unregisters the name, you receive gen.MessageExitProcessID. LinkAlias works with process aliases - termination or alias deletion triggers gen.MessageExitAlias.
You can also link to node connections with LinkNode. If the connection to the specified node is lost, you receive gen.MessageExitNode. This is useful for processes that can't operate when a particular remote node is unavailable.
The generic Link method takes four target types and dispatches to the typed method for each: gen.PID, gen.ProcessID, gen.Alias, and gen.Atom - which it reads as a registered name on the local node, not as a node name. Anything else returns gen.ErrUnsupported. Monitor, Unlink and Demonitor behave the same way.
That means node and event targets are not reachable through the generic form: Link(gen.Atom("other@host")) looks for a local process registered under that name and fails with gen.ErrProcessUnknown, and Link(gen.Event{...}) returns gen.ErrUnsupported. Call LinkNode and LinkEvent directly.
Links in Ergo are unidirectional, and this deserves emphasis because it differs from Erlang.
When you execute process.LinkPID(target), you establish a relationship where target's termination affects you. The link points from you to the target. If the target terminates, you receive an exit signal. But if you terminate, the target is unaffected. The link doesn't point backward.
Erlang's links are bidirectional. If process A links to process B in Erlang, either terminating causes the other to terminate. This symmetry can be useful, but it also creates unexpected cascading failures. In Ergo, if you want bidirectional coupling, you create two links: A links to B, and B links to A.
The unidirectional design gives you precise control. Consider a shared service with multiple workers. Each worker links to the service (if the service dies, workers should too). But the service doesn't link back to workers (a worker crash shouldn't kill the service). Unidirectional links express this asymmetric dependency naturally.
Monitors provide lifecycle awareness without lifecycle coupling. You track when something terminates, but you don't terminate yourself.
The quintessential monitor use case is supervision. A supervisor monitors worker processes. When a worker terminates, the supervisor receives a down message. The message includes the worker's PID or identifier and the termination reason. The supervisor examines this information, consults its restart strategy, and decides whether to spawn a replacement. The supervisor continues running regardless of how many workers have crashed.
Down messages arrive in the System queue with high priority (but lower than Urgent exit signals). Every gen.MessageDown* type includes a Reason field except gen.MessageDownNode, which carries only the node name. For MonitorPID, you receive gen.MessageDownPID with the target's PID and reason. For MonitorProcessID, you receive gen.MessageDownProcessID with the registered name and reason. The reason might indicate normal termination, a crash, or a special case like name unregistration (gen.ErrUnregistered).
Monitoring registered names or aliases handles invalidation gracefully. If you monitor a process by name and that process unregisters its name, you receive a down message with reason gen.ErrUnregistered. The process might still be running, but it's no longer accessible by that name, which is what you were monitoring. Same logic applies to alias deletion - you're notified that the thing you were monitoring is no longer valid.
Node monitoring tracks connection health. MonitorNode sends you gen.MessageDownNode when the connection to a remote node is lost. It has one field, Name - there is no reason to read, because losing the connection is the whole of the news. This is useful for detecting network partitions or remote node crashes without linking (which would terminate your process).
Links and monitors work across nodes without changing their semantics or your code.
When you link or monitor a remote target, the framework sends a request to the remote node. The remote node records that your process is watching the target. This setup happens during your Link* or Monitor* call and involves a network round-trip. The operation can fail if the remote node is unreachable or the target doesn't exist - check the error return.
Once established, the remote node tracks your subscription. When the target terminates on the remote node, the remote node sends a notification message back to your node. Your node routes it to your process's mailbox. From your perspective, it's just another message - you don't see the network mechanics.
Network failures complicate this. If the connection to the remote node fails while your link or monitor is active, your local node detects the disconnection. It looks up which local processes had links or monitors to targets on that failed node. For links, it sends exit signals with reason gen.ErrNoConnection. For monitors, it sends down messages with the same reason.
This unified handling means you write the same error handling code for local and remote targets. The notification mechanism is consistent. The reason field distinguishes between target termination and network failure, but the notification path is identical.
Links and monitors aren't permanent. You can remove them explicitly or they're removed automatically when participants terminate.
To remove a link, use the corresponding Unlink* method with the same target. UnlinkPID, UnlinkProcessID, UnlinkAlias, UnlinkNode each remove the link created by their Link* counterpart. If you never created the link, unlinking returns an error. For monitors, the Demonitor* methods work the same way.
When the target terminates and you receive notification, the link or monitor is automatically removed. You receive one notification per relationship. If the target is later restarted (by a supervisor), you won't receive notification about that new instance unless you create a new link or monitor to it.
When you terminate (the process that created the link or monitor), your relationships are cleaned up automatically. The target doesn't receive notification that you stopped watching. This asymmetry is intentional - the target doesn't track who's watching it, so it doesn't care when watchers go away.
Several common patterns emerge from combining links and monitors.
Workers often link to infrastructure processes they depend on. A worker processing HTTP requests might link to a database connection pool process. If the pool terminates (perhaps during a deployment), the worker receives an exit signal and terminates. The worker's supervisor detects the termination, waits a moment (hoping the database pool restarts), and spawns a new worker. The new worker links to the (now running) pool and resumes processing.
Supervisors monitor their children. Each worker termination triggers a down message. The supervisor checks the reason. If it's gen.TerminateReasonNormal, the worker finished its task and doesn't need restart. If it's an error or panic, the supervisor spawns a replacement. The supervisor's continued operation despite worker failures is the whole point of the supervisor pattern.
Load balancers monitor backend processes. Each backend termination updates the balancer's routing table. The balancer continues routing to available backends. When a backend restarts, it might need to register with the balancer, which would then monitor it again.
Parent-child relationships often use LinkChild and LinkParent options in gen.ProcessOptions. These provide a convenient way to create links automatically after process initialization completes. You can also call Link methods directly during initialization if needed. If either participant terminates, the other receives an exit signal.
Links propagate failure. Monitors report failure. Choose based on whether the watcher should terminate when the target terminates.
If continued operation without the target is meaningless, use a link. If you can adapt to the target's absence (by finding a replacement, degrading gracefully, or restarting the target), use a monitor.
The unidirectional nature of links matters more than you might initially think. It lets you express asymmetric dependencies precisely. Workers depend on services, but services don't depend on individual workers. Clients depend on servers, but servers don't depend on individual clients. Links point from the dependent to the dependency, making the relationship clear.
For event-based publish/subscribe patterns using links and monitors, see the chapter. For supervision trees built on monitors, see .
The rotate logger writes log messages to files with automatic rotation based on time intervals. Instead of a single growing log file that eventually fills the disk, the logger creates new files periodically and optionally compresses old ones. This keeps disk usage predictable and makes log files manageable for analysis and archival.
The logger operates asynchronously - log messages enter a queue and a background goroutine writes them to the file. This design prevents blocking your processes when disk I/O is slow. Logging happens in the background while your actors continue processing messages without waiting for disk writes to complete.
Rotation happens based on time periods. You configure a duration - one minute, one hour, one day - and the logger creates a new file every period. The active file is always named <Prefix>.log. When the period ends, the logger:
Copies the active file to a timestamped filename: <Prefix>.YYYYMMDDHHmi.log
Optionally compresses it with gzip: <Prefix>.YYYYMMDDHHmi.log.gz
Truncates the active file to start fresh for the new period
Deletes old files if depth limit is configured
This approach ensures the active file always has the same name. You can tail it (tail -f <Prefix>.log) and it works across rotations. The timestamped copies accumulate in the log directory, creating a chronological archive.
The timestamp format is YYYYMMDDHHmi - year, month, day, hour, minute. This format sorts lexicographically, so ls -l shows files in chronological order. It's compact but human-readable.
The logger uses an internal lock-free queue (MPSC - multi-producer single-consumer). When any process logs a message, it pushes to the queue and returns immediately. A single background goroutine pops messages from the queue and writes them to the file.
This design has several advantages:
Non-blocking - Logging never blocks your process. If the disk is slow or the file system stalls, your actors continue running. The queue absorbs bursts of messages.
Ordering - Messages from a single producer maintain order. The queue preserves submission order, so logs reflect the actual sequence of events within each process.
Batching - The background goroutine processes messages continuously. If multiple messages arrive quickly, it writes them in a tight loop, reducing syscall overhead.
The logger requires a rotation period and accepts several optional parameters:
Period - The rotation interval. Minimum is time.Minute. Smaller periods create more files with less data each. Larger periods create fewer files with more data each. Choose based on how you analyze logs - if you search specific time ranges, shorter periods help. If you archive logs by day, use 24 * time.Hour.
Path - Directory for log files. Defaults to ./logs relative to the executable. The logger creates the directory if it doesn't exist. Supports ~ for home directory expansion (~/logs becomes /home/user/logs). Use absolute paths in production to avoid ambiguity.
Prefix - Filename prefix. Defaults to the executable name. The active file is <Prefix>.log, rotated files are <Prefix>.YYYYMMDDHHmi.log[.gz]. Use meaningful prefixes if multiple services log to the same directory.
Compress - Enables gzip compression for rotated files. The active file stays uncompressed for fast writing. When rotating, the logger compresses the copy, reducing disk usage by 5-10x for text logs. Compressed files have .log.gz extension. Use compression if disk space matters more than CPU for compression.
Depth - Limits the number of retained log files. When rotating, if the number of files exceeds Depth, the logger deletes the oldest file. Set to 0 (default) for unlimited retention. Set to a specific number (e.g., 24) to keep the last 24 periods. This prevents unbounded disk usage.
TimeFormat - Timestamp format in log messages. Same as colored logger - any format from time package or custom layout. Empty string uses nanosecond timestamps. Choose based on readability vs. precision.
IncludeName - Includes registered process names in log messages. Helps identify which process logged what.
IncludeBehavior - Includes behavior type names in log messages. Useful during development to understand code flow.
ShortLevelName - Uses abbreviated level names ([TRC], [DBG], etc.) instead of full names. Saves space in log files.
Configure the rotate logger in node options:
This configuration:
Rotates every hour
Stores logs in /var/log/myapp/
Names files myapp.log (active) and myapp.202411191200.log.gz (rotated with compression)
Depth is not retention by age, and not by what is in the directory. The logger keeps an in-memory list of the files it has rotated during its own lifetime and removes the oldest only once that list grows past Depth. It never scans the directory at startup, so files left by an earlier run are neither counted nor deleted - restart the node every hour with Depth: 24 and the directory grows without bound. To bound disk use across restarts, do it outside the logger: logrotate, a cron sweep, or a volume with its own retention.
For detailed logger configuration options, see the rotate.Options struct in the package. For understanding how loggers integrate with the framework, see .
Ergo Service Registry and Discovery
saturn is a tool designed to simplify the management of clusters of nodes created using the Ergo Framework. It offers the following features:
A unified registry for node registration within a cluster.
The ability to manage multiple clusters simultaneously.
The capability to manage the configuration of the entire cluster without restarting the nodes connected to Saturn (configuration changes are applied on the fly).
Notifications to all cluster participants about changes in the status of applications running on nodes connected to Saturn.
The source code of the saturn tool is available on the project's page: .
To install saturn, use the following command:
Available arguments:
host: Specifies the hostname to use for incoming connections.
port: Port number for incoming connections. The default value is 4499.
path: Path to the configuration file
To start Saturn, a configuration file named saturn.yaml is required. By default, Saturn expects this file to be located in the current directory. You can specify a different location for the configuration file using the -path argument.
You can find an example configuration file in the project's Git repository.
The saturn.yaml configuration file contains two root elements:
Saturn: This section includes settings for the Saturn server.
You can configure the Token for access by remote nodes and specify certificate files for TLS connections.
By default, a self-signed certificate is used. For clients to accept this certificate, they must enable the InsecureSkipVerify
If the name of a configuration element ends with the suffix .file, the value of that element is treated as a file. The content of this file is then sent to the nodes as a []byte.
To configure settings for all nodes in all clusters, use the Clusters section in the saturn.yaml configuration file. Here, you can define global settings that will apply to every node within every cluster managed by Saturn:
in this example:
Var1, Var2, Var3, and Var4 will be applied to all nodes in all clusters.
However, the value of Var1 for nodes named node@host.local in any cluster will be overridden with the value 456.
If nodes are registered without specifying a Cluster in saturn.Options, they become part of the general cluster. Configuration for the general cluster should be provided in the Cluster@ section
In the example above:
The variable Var1 is set to 789 for the general cluster (all nodes in the general cluster will receive Var1: 789).
However, for the node node@host.local within the general cluster, Var1 will be overridden to 456.
Thus, all nodes in the general cluster will inherit Var1: 789, except for node@host.local, which will specifically have Var1: 456. Other nodes in the general cluster will retain the default values from the Cluster@ section unless they are explicitly overridden in the configuration.
To specify settings for a particular cluster, use the element name Cluster@<cluster name> in the configuration file:
Saturn can manage multiple clusters simultaneously, but resolve requests from nodes are handled only within their own cluster.
The name of a registered node must be unique within its cluster.
When a node registers, it informs the registrar which cluster it belongs to. Additionally, the node reports the applications running on it. Other nodes in the same cluster receive notifications about the newly connected node and its applications. Any changes in application statuses are also reported to the registrar, which in turn notifies all participants in the cluster.
For more details, see the section.
The colored logger provides visual clarity for console output by applying color highlighting to log messages. Instead of monochrome text where errors blend with informational messages, each log level gets a distinct color, and framework types are highlighted automatically. This makes it easier to scan logs during development and debugging.
The logger writes directly to standard output with immediate formatting - no buffering, no delays. When a process logs a message, it appears instantly in your terminal with colors applied. This synchronous approach keeps logs simple and predictable during interactive development.
Color helps your eyes parse logs quickly. Log levels use consistent colors:
Trace - Faint white (low importance, background noise)
Debug - Magenta (development information)
Info - White (normal operation)
Warning - Yellow (attention needed)
Error - Red bold (problems occurred)
Panic - White on red background bold (critical failures)
Framework types also get color highlighting:
gen.Atom - Green (names and identifiers)
gen.PID - Blue (process identifiers)
gen.ProcessID - Blue (named processes)
When you log process.Log().Info("started %s", pid), the PID renders in blue automatically. You don't annotate it - the logger detects the type and applies color. This works for any framework type used as an argument.
Each log message follows a consistent structure:
Timestamp appears first. By default, it's the Unix timestamp in nanoseconds. You can configure any format from Go's time package, or define your own. Nanosecond timestamps are sortable and precise, useful when correlating logs with traces or metrics.
Level shows the severity. The bracket format [INFO] or short form [INF] makes levels easy to grep. Color reinforces the level visually - you don't need to read the text to know something is an error.
Source identifies where the message originated:
Node logs - Show the node name in green (CRC32 hash for compactness)
Network logs - Show both local and peer node names
Process logs - Show PID in blue, optionally the registered name in green, optionally the behavior type
Meta-process logs - Show alias in cyan, optionally the behavior type
The optional components (name, behavior) are controlled by configuration. During development, you might want behavior names to understand which actor logged something. In production, you might omit them to reduce output.
Message is your formatted string with arguments. Framework types in arguments get color highlighting automatically.
The logger accepts several options during creation:
TimeFormat - Sets timestamp format. Any format from time package works (time.RFC3339, time.Kitchen, custom layouts). Leave empty for nanosecond timestamps. Nanoseconds are precise but hard to read. RFC3339 is human-friendly but verbose. Choose based on your use case.
ShortLevelName - Uses abbreviated level names: [TRC], [DBG], [INF], [WRN], [ERR], [PNC]. Saves horizontal space in the terminal. Full names are clearer for people unfamiliar with the abbreviations.
IncludeName - Adds the registered process name to the source. If a process registers as "worker", logs show the name in green next to the PID. Helpful when you have many processes and want to identify them by role rather than PID.
IncludeBehavior - Adds the behavior type name to the source. Logs show which actor implementation generated the message. Useful during development to understand code flow. In production, this adds noise if you have good message content.
IncludeFields - Includes structured logging fields in the output. Fields appear below the message with faint color. Useful when your log messages use context fields for correlation (request IDs, user IDs, etc.).
DisableBanner - Disables the Ergo logo banner on startup. The banner announces framework version and adds visual flair. Disable it in production or when running tests where the banner clutters output.
Register the colored logger in node options:
The default logger writes to stdout too, but without colors. If you don't disable it, you get each message twice - once colored, once plain. Disabling the default logger ensures only the colored version appears.
For detailed logger configuration options, see the colored.Options struct in the package. For understanding how loggers integrate with the framework, see .
This package implements the gen.Registrar interface and serves as a client library for the central registrar, Saturn. In addition to the primary Service Discovery function, it automatically notifies all connected nodes about cluster configuration changes.
To create a client, use the Create function from the saturn package. The function requires:
The hostname where the central registrar is running (default port: 4499, unless specified in saturn.Options)
A token for connecting to Saturn
a set of options saturn.Options
Then, set this client in the gen.NetworkOption.Registrar options
Using saturn.Options, you can specify:
Cluster - The cluster name for your node
Port - The port number for the central Saturn registrar
KeepAlive - The keep-alive parameter for the TCP connection with Saturn
When the node starts, it will register with the central registrar in the specified cluster.
Additionally, this library registers a gen.Event and generates messages based on events received from the central Saturn registrar within the specified cluster. This allows the node to stay informed of any updates or changes within the cluster, ensuring real-time event-driven communication and responsiveness to cluster configurations:
saturn.EventNodeJoined - Triggered when another node is registered in the same cluster.
saturn.EventNodeLeft - Triggered when a node disconnects from the central registrar
saturn.EventApplicationLoaded - An application was loaded on a remote node. Use ResolveApplication from the gen.Resolver
To receive such messages, you need to subscribe to Saturn client events using the LinkEvent or MonitorEvent methods from the gen.Process interface. You can obtain the name of the registered event using the Event method from the gen.Registrar interface. This allows your node to listen for important cluster events like node joins, application starts, configuration updates, and more, ensuring real-time updates and handling of cluster changes.
Using the saturn.EventApplication* events and the feature, you can dynamically manage the functionality of your cluster. The saturn.EventConfigUpdate events allow you to adjust the cluster configuration on the fly without restarting nodes, such as updating the cookie value for all nodes or refreshing the TLS certificate. Refer to the section for more details.
You can also use the Config and ConfigItem methods from the gen.Registrar interface to retrieve configuration parameters from the registrar.
To get information about available applications in the cluster, use the ResolveApplication method from the gen.Resolver interface, which returns a list of gen.ApplicationRoute structures:
Name The name of the application
Node The name of the node where the application is loaded or running
Weight The weight assigned to the application in gen.ApplicationSpec
You can access the gen.Resolver interface using the Resolver method from the gen.Registrar interface.
How to test actor systems in Ergo, and which layer to use
An actor is not a function. You cannot call it and inspect a return value. It runs on its own goroutine, communicates only through messages, keeps private state that evolves from one message to the next, spawns children, and can be terminated or restarted by a supervisor. A test that reaches into an actor's fields tests the wrong thing and breaks the isolation the model depends on.
So the testing tools observe an actor the way the rest of the system does: through what it does. Every outward action - a message sent, a process spawned, a log line written, an exit signal, a timer scheduled - is captured as a record. You drive the actor with inputs - deliver a message, fire a timer, deliver an exit - and assert on the records it produced. You test behavior, not state. That single idea runs through all four packages this section covers.
The shape of every test is the same: drive an input, then assert on an output.
sub.SendMessage(client, "ping")
sub.ShouldSend().To(client).Message("pong").Once().Assert()The actor received a message and sent one back. The harness recorded the send, and the assertion checks that it happened exactly once, to the right target, with the right payload. The same fluent grammar - a Should... builder, filters, a count, a terminal - describes every kind of action, and it reads the same whether the actor runs in a mock or on a live node. There is no result := actor.Process(msg) to inspect, because actors do not work that way; instead you verify the messages an actor sends, the children it spawns, the events it emits, and how it terminates.
Four packages cover the span from a single function up to a cluster of real nodes. They share one assertion grammar and line up along one axis: how much of the real system is present.
check is the language, and nothing more. It defines the record types and the fluent Should... grammar every test is written in. You rarely import it directly - unit and stage expose its assertions on their own handles - but reading it once teaches the vocabulary the other layers reuse: cardinalities, filters, scoping, and value capture.
mock adds a controllable dependency, for code that is not an actor. It provides a standalone fake of each gen.* interface - Node, Process, MetaProcess, Log, Cron, Network, RemoteNode, Registrar, Resolver - to inject into ordinary code that consumes one: a helper, a resolver, a constructor. Override the methods you care about; the rest return safe defaults.
A second constructor with a T suffix also records every call, so you can assert on what the code did with the dependency:
unit brings one actor to life against a mock node. It spawns a single behavior, lets you drive its callbacks - deliver messages, fire timers, deliver exits and downs - and asserts on what it does. It runs synchronously: no real goroutines, no clock, fully deterministic and fast. This is where most actor logic is tested.
stage brings the whole runtime to life. It starts real nodes, runs real actors and applications, lets them talk over the real network, and observes what the live runtime does. Use it for what unit deliberately leaves out: the real scheduler, supervision and restarts, links and monitors across nodes, remote spawn, disconnects. Because everything runs for real and concurrently, assertions wait with Within instead of reading a snapshot.
Testing one actor's message handling, spawning, logging, or lifecycle: use unit.
Testing a helper or component that takes a gen.Node or gen.Process and you need to control what it returns: use mock.
Testing behavior that needs the real scheduler, the real network, or more than one node: use stage.
unit and stage are not two ways to write the same test; they answer different questions. A typical project tests the bulk of its actor logic with unit, where tests run in microseconds and never flake, and reserves stage for the cross-node and supervision scenarios that only the real runtime exhibits.
Read on in order: for the grammar every test is written in, then , , and , each building on the one before.
Mutual TLS authentication between nodes
Standard TLS provides server authentication - the client verifies the server's certificate. Mutual TLS (mTLS) adds client authentication - both sides present and verify certificates. Only clients with certificates signed by a trusted CA can connect.
NodeOptions.CertManager is used for:
Default acceptor (created automatically on gen.DefaultPort, 11144)
All outgoing connections
Vet Tool for Ergo Actor Model Invariants
The actor model rests on rules the Go compiler cannot check. A message must not share memory with its sender. A callback must not block without a bound. A goroutine started inside a callback must not reach back into actor state. Break any of these and the code compiles, the tests pass, and the failure arrives later - as a race under load, as a mailbox that never drains, as a node that reports itself healthy while serving nothing.
argus reads your packages and reports those breaks. It is a vet tool, so it runs the way go vet runs: over the build graph, with your build tags, cached per package.
In production, as a vet tool. The go command drives it and caches the results:
During development, directly over package patterns:
Package patterns resolve against the main module of the working directory, exactly as the go command resolves them. To analyse a module you are not standing in, move there first:
Every finding carries a tier and a rule ID. The tier says how much to trust it, and decides whether it fails your build:
Keeps the last 24 files this process rotated
saturn.yamldebug: Enables debug mode for outputting detailed information.
version: Displays the current version of Saturn.
Changes to this section require a restart of the Saturn server.
Clusters: This section includes the configurations for clusters.
Changes in this section are automatically reloaded and sent to the registered nodes as updated configuration messages, without requiring a restart of Saturn.
The settings can target:
All nodes in all clusters.
Only nodes with a specified name in all clusters.
Only nodes within a specific cluster.
Only a node with a specified name within a specific cluster.
gen.Ref - Cyan (references)
gen.Alias - Cyan (meta-process identifiers)
gen.Event - Cyan (event names)
InsecureSkipVerify - Option to ignore TLS certificate verification
saturn.EventApplicationStarted - Triggered when an application starts on a remote node.
saturn.EventApplicationStopping - Triggered when an application begins stopping on a remote node.
saturn.EventApplicationStopped - Triggered when an application is stopped on a remote node.
saturn.EventApplicationUnloaded - Triggered when an application is unloaded on a remote node
saturn.EventConfigUpdate - The node's configuration was updated
Mode The application's startup mode (gen.ApplicationModeTemporary, gen.ApplicationModePermanent, gen.ApplicationModeTransient)..
State The current state of the application (gen.ApplicationStateLoaded, gen.ApplicationStateInitializing, gen.ApplicationStateRunning, gen.ApplicationStateStopping)
Tags Labels for filtering and selecting instances - the field a blue/green, canary or maintenance selection is made on
Version Lets a resolver tell instances of the same application apart during a rolling upgrade
Understanding what ShouldSend, Within, Once, or Capture mean in any of the above: read check.
package main
import (
"time"
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/logger/rotate"
)
func main() {
options := rotate.Options{
Period: time.Hour,
Path: "/var/log/myapp",
Prefix: "myapp",
Compress: true,
Depth: 24,
}
logger, err := rotate.CreateLogger(options)
if err != nil {
panic(err)
}
nodeOpts := gen.NodeOptions{
Log: gen.LogOptions{
Loggers: []gen.Logger{
{Name: "rotate", Logger: logger},
},
},
}
node, err := ergo.StartNode("demo@localhost", nodeOpts)
if err != nil {
panic(err)
}
node.Log().Info("Node started, logging to /var/log/myapp/myapp.log")
node.Wait()
}$ go install ergo.tools/saturn@latestClusters:
Var1: 123
Var2: 12.3
Var3: "123"
Var4.file: "./myfile.txt"
node@host.local:
Var1: 456Clusters:
Var1: 123
Cluster@:
Var1: 789
node@host:
Var1: 456Clusters:
Var1: 123
Cluster@mycluster:
Var1: 321
node@host: 654<timestamp> <level> <source> [name] [behavior]: <message>package main
import (
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/logger/colored"
)
func main() {
behavior, err := colored.CreateLogger(colored.Options{})
if err != nil {
panic(err)
}
logger := gen.Logger{
Name: "colored",
Logger: behavior,
}
options := gen.NodeOptions{}
options.Log.Loggers = []gen.Logger{logger}
// Disable default logger to avoid duplicate output
options.Log.DefaultLogger.Disable = true
node, err := ergo.StartNode("demo@localhost", options)
if err != nil {
panic(err)
}
node.Log().Info("Node started with colored logger")
node.Wait()
}import (
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/registrar/saturn"
)
func main() {
var options gen.NodeOptions
...
host := "localhost"
token := "IwOBhgAEAGzPt"
options.Network.Registrar = saturn.Create(host, token, saturn.Options{})
...
node, err := ergo.StartNode("demo@localhost", options)
...
}type myActor struct {
act.Actor
}
func (m *myActor) HandleMessage(from gen.PID, message any) error {
reg, e := a.Node().Network().Registrar()
if e != nil {
a.Log().Error("unable to get Registrar interface %s", e)
return nil
}
ev, e := reg.Event()
if e != nil {
a.Log().Error("Registrar has no registered Event: %s", e)
return nil
}
a.MonitorEvent(ev)
return nil
}
func (m *myActor) HandleEvent(event gen.MessageEvent) error {
m.Log().Info("got event message: %v", event)
return nil
}type ApplicationRoute struct {
Node Atom
Name Atom
Weight int
Mode ApplicationMode
Tags []Atom
State ApplicationState
Version Version
}sub.ShouldSend().To(gen.Atom("db")).Message(SaveUser{ID: 7}).Once().Assert() // exactly once
sub.ShouldSpawn().Times(3).Assert() // a count
sub.ShouldSend().To(gen.Atom("audit")).None().Assert() // never happened
child, _ := sub.ShouldSpawn().Once().Capture() // grab the resultdb := mock.NewProcess()
db.OnCall(func(to, request any) (any, error) { return Row{ID: 7}, nil })
saveUser(db) // the code under test holds db as a gen.Processnode := mock.NewNodeT(t)
bootstrap(node) // the code under test calls node.Spawn, node.Log, ...
node.ShouldSpawn().Times(3).Assert()sub, _ := unit.Spawn(t, factoryWorker, gen.ProcessOptions{})
sub.SendMessage(client, StartJob{ID: "42"})
sub.ShouldSend().To(gen.Atom("scheduler")).Message(JobQueued{ID: "42"}).Once().Assert()s := stage.New(t)
n := s.StartNode("n")
worker := n.Spawn(factoryWorker, gen.ProcessOptions{})
n.Send(worker, Job{ID: "42"})
n.ShouldDeliver().To(worker).Message(Job{ID: "42"}).Within(time.Second).Once().Assert()AcceptorOptions.CertManager.gen.CertAuthManager extends CertManager with CA pool and authentication settings:
Every setter is paired with a getter, and the getters are not decoration: they are what the network stack calls. A type that implements only the four setters does not satisfy the interface.
Server-side settings:
ClientCAs
CA pool to verify client certificates
ClientAuth
How strictly to enforce client certificates
ClientAuth values:
tls.NoClientCert
Don't request client certificate (default)
tls.RequestClientCert
Request but don't require
tls.RequireAnyClientCert
Client-side settings:
RootCAs
CA pool to verify server certificates
ServerName
Server name for SNI (if different from host)
Certificates can be rotated without restart:
New connections use the updated certificate. Existing connections keep their original certificate.
CA pools and ClientAuth are rotatable too, on a gen.CertAuthManager. The listener installs a per-connection TLS callback, so every incoming handshake re-reads ClientCAs() and ClientAuth() from the manager: calling SetClientCAs or SetClientAuth takes effect on the live listener, for the next connection, with no restart. Outgoing connections read RootCAs() and ServerName() at dial time, so those apply from the next dial. Only connections already established keep the settings they were made with.
To use different certificates for specific destinations, see Static Routes.
Connection rejected with certificate error
Verify the client certificate is signed by a CA in the server's ClientCAs pool. Check certificate expiration dates.
Server certificate verification failed
The server's certificate must be signed by a CA in the client's RootCAs pool. For development, disable verification with NetworkOptions.InsecureSkipVerify: true.
SNI mismatch
Set ServerName on the client's CertAuthManager if the certificate's Common Name doesn't match the connection address.
Certificate rotation not taking effect
Updates apply to new connections only. Close existing connections to force reconnection with new certificate.
CA pool changes not taking effect
Check that the manager is a gen.CertAuthManager and that it is the one the acceptor holds - the per-connection re-read only happens for that type. Changes apply to the next connection, not to open ones, so an existing connection has to be closed to be re-verified.
func startSecureNode(name string) (gen.Node, error) {
// Load node certificate (signed by cluster CA)
cert, err := tls.LoadX509KeyPair(
fmt.Sprintf("%s.pem", name),
fmt.Sprintf("%s-key.pem", name),
)
if err != nil {
return nil, err
}
// Load cluster CA
caCert, err := os.ReadFile("cluster-ca.pem")
if err != nil {
return nil, err
}
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caCert)
certManager := gen.CreateCertAuthManager(cert)
certManager.SetClientCAs(caPool) // verify incoming
certManager.SetClientAuth(tls.RequireAndVerifyClientCert) // require client cert
certManager.SetRootCAs(caPool) // verify outgoing
return ergo.StartNode(gen.Atom(name), gen.NodeOptions{
CertManager: certManager,
})
}type CertAuthManager interface {
CertManager
// server-side: CA pool to verify client certificates
SetClientCAs(pool *x509.CertPool)
ClientCAs() *x509.CertPool
// client-side: CA pool to verify server certificates
SetRootCAs(pool *x509.CertPool)
RootCAs() *x509.CertPool
// server-side: client authentication policy
SetClientAuth(auth tls.ClientAuthType)
ClientAuth() tls.ClientAuthType
// client-side: server name for SNI and verification
SetServerName(name string)
ServerName() string
}newCert, _ := tls.LoadX509KeyPair("new.pem", "new-key.pem")
certManager.Update(newCert)1
error
The invariant is broken. The failure is a matter of timing, not of whether.
2
warning
The rule ID explains itself:
Read that second command before arguing with a finding. Each rule documents its own blind spots, and several of them are narrower than their titles suggest.
Forty-four rules in this build, grouped by what they protect.
A1xxx - the actor model itself. Shared memory in a message (A1001), an unbounded wait in a callback (A1002), a call to your own identity (A1003), a goroutine touching actor state (A1004), meta state written from both meta goroutines (A1005), sending a field of your own state (A1006), internal memory escaping across an actor boundary (A1007), HandleCall returning an error in the reason slot (A1008), a goroutine with no panic boundary (A1010), a blocking request in Init against the init budget (A1011), routing through the node handle from inside an actor (A1012).
A2xxx - using the framework as it works. A state-gated API called where its state forbids it (A2001), a discarded result hiding a certain failure (A2001a), a call the runtime refuses on its arguments whatever the state (A2026), a HandleCall that can never reply (A2002) or replies twice (A2003), a supervisor spec the framework rejects at init (A2005), router or pool options it rejects the same way (A2025), timer misuse (A2006), a self-timer chain armed from two places (A2015), an event that can never be published (A2007), a web request never completed (A2008), a spawn argument shared with the parent (A2011), logging a transient failure and then returning it (A2012), discarding the buffered events a subscription returns (A2013), a round trip in Terminate (A2014), Terminate dereferencing what Init may never have assigned (A2014a), identity established in a handler Init sent to itself (A2016), Init returning a framework sentinel as control flow (A2017), an application Init that returns an error after taking a resource nothing releases (A2023), Notify set while the producer handles neither start nor stop (A2018), a meta Start that returns at once and so ends the meta (A2027).
Two of that group are about how long a callback holds its mailbox. A round trip inside a callback parks the mailbox for everyone queued behind it (A2024). A request made inside HandleCall on the default timeout has no budget at all, because the caller is waiting on the same five seconds and will have given up before the reply exists (A2028) - the fix is either a smaller inner timeout or the deferred reply of Sync Request Handling.
Five are about what survives the trip to another node: a message type that cannot be serialized (A2004), a type registration that fails at startup (A2009), a type reachable from a registered one that nobody registers (A2021), an fmt.Errorf value carrying %w put on the wire, which arrives as text with its identity gone (A2020), and a sentinel put on the wire that this node never registered (A2022).
A3xxx - conventions. A message type with no marker (A3001), a message type missing from the registration list (A3003), suppression debt (A3005), prose that should be a marker (A3006), a compression threshold below the framework floor (A3007).
The numbering has gaps: A1009, A2010, A2019, A3002 and A3004 are not in this build. An identifier is permanent once published, because it appears in configuration files, baselines and source directives, so a rule that is retired - or designed and not shipped - keeps its number rather than having it reused.
The first run on a codebase that has never seen the tool reports findings that are all true and none of which are getting fixed today. Take a baseline, and the run goes quiet until something new appears:
Findings already in the baseline stay silent. Anything new is reported.
A codebase written before the tool existed reports plenty. application/observer reports A1001 broadly, because any message carrying a map[string]any or a slice shares memory on local delivery, and local delivery does not copy. Those findings are correct; whether they matter depends on whether the sender touches the value afterwards.
That is the tool's shape in general: it reports what it can prove about the construct, and leaves the judgement about your specific case to you. Read new findings as signal, not as verdicts.
go install ergo.tools/argus@latestgo vet -vettool=$(which argus) ./...argus ./...argus -C ../../application/observer ./...gen/cron_action.go:103:4: [tier2] [A2011] []any is passed to Spawn and shares
unsynchronized memory with the parent: []interface{} -> any; the child runs on its
own goroutine, so pass a copy or a type that guards itselfargus help every rule in this build
argus help A1002 one rule in full - what it reports, what it deliberately does notargus -argusmodel.keys ./... 2>&1 | argus baseline > argus-baseline.jsonThe sentry logger forwards panics and errors to a Sentry project, complete with stack traces pointing at the panic origin. When a production node recovers a panic in an actor callback, that event is usually written to a file and noticed hours later, if at all. Sentry-backed logging surfaces the same event in your issue tracker within seconds, grouped by panic value, with the originating frames marked as application code.
The logger does not replace your console or file logger. It runs alongside them and captures a narrow slice of the log stream: every panic from anywhere in the framework, and errors from the higher-level subsystems (node, network, application). Meta processes and individual actors are off by default to keep noisy actor-level errors from drowning your Sentry quota, and can be turned on per source when needed.
Two severities are forwarded; the others stay local. The matrix maps source against level:
Every panic event reaches Sentry regardless of which subsystem produced it. The framework recovers panics in actor callbacks, supervisors, pools, web workers, meta processes, applications, cron jobs and network handlers; each recovery site formats a Log().Panic() message which the logger sees and captures.
Error-level events from the node, network and application subsystems are forwarded by default. These are the operational layer: a network handshake failure, an application that refuses to start, a node configuration error. They tend to be infrequent and worth a Sentry issue every time.
Error-level events from meta processes and individual actors are opt-in via CaptureMetaErrors and CaptureProcessErrors. A misbehaving actor can produce many errors per second, and you usually want to investigate those in your local logs first rather than pay for ingest in Sentry. Turn the flag on when you have specific signals you want to track centrally.
Lower levels (warning, info, debug, trace) are never forwarded. Sentry is for things that need attention, not a general log sink.
For panic events the logger captures the goroutine stack at the moment the panic was recovered. The captured stack points at the line of code that actually panicked rather than at the framework's recovery wrapper. In Sentry this appears as an Exception with Type: "panic", value set to the recovered panic value, and a stack trace with caller-first ordering.
Frames are marked as application code by default; frames from the Go runtime, the Sentry SDK and ergo.services/* are tagged as framework code so Sentry's UI collapses them out of the way and highlights the lines that belong to your project.
Sentry groups events by the Exception.Value. Since that value is the recovered panic message (nil pointer dereference, index out of range, your own panic string), every reoccurrence of the same root cause ends up in the same issue rather than scattered by the surrounding format string.
If you wrap Log().Panic() in a helper layer of your own, increase SkipFrames so the captured stack still trims your wrapper out and starts where the panic actually originated.
Every event carries a source tag identifying which subsystem produced it: node, network, application, meta or process. Additional tags depend on the source: node and network events include the node name (and peer name for network); application events include the application name and run mode; meta and process events include their identifier (alias or PID) and optionally the behavior type.
Structured fields attached via Log().AddFields() arrive in Sentry as Extra data. The same fields you use to correlate logs locally show up in the Sentry event panel, so you can filter or pivot on request_id, user_id, or any other context you have already wired through your logging path.
The Log method is called synchronously by the framework. To avoid holding up the logging path, the sentry logger accepts the message and hands it off to a background worker that builds and ships the Sentry envelope. The framework continues without waiting for network I/O to Sentry.
When the internal queue is full, new events are dropped silently. The queue cap exists to bound memory under a panic storm; on a well-behaved system it stays empty most of the time. If you see drops in practice, raise QueueLimit or investigate the actor that keeps panicking.
Terminate() is given a bounded amount of time to drain the queue and flush events that the Sentry SDK has already buffered for transport. After that window the logger gives up and the node exits.
The logger creates its own Sentry client and hub. It does not call sentry.Init() and does not touch the global sentry.CurrentHub(). If you already use the Sentry SDK directly elsewhere in your process, the integrations do not interfere with each other.
The logger accepts the following options:
DSN - Sentry project DSN. Leave empty to fall back to the SENTRY_DSN environment variable as handled by the Sentry SDK. Required either inline or via environment.
Environment - Tag attached to every event. Common values are production, staging, development. Sentry uses this to filter and segment issues.
Release - Version of the running binary. When set, Sentry can link an issue to the deploy that introduced it and surface regressions across releases.
ServerName - Override for the auto-detected hostname. Useful when running in containers where the default hostname is meaningless.
CaptureMetaErrors - Forwards error-level events from meta processes. Off by default. Turn on when meta-process errors are operationally relevant.
CaptureProcessErrors - Forwards error-level events from individual actors. Off by default. Turn on selectively; an actor in a bad state can produce many errors per second.
QueueLimit - Cap on the internal event queue. Events past the cap are dropped. The default is conservative; raise it if you expect panic storms and want to keep more events in flight rather than drop them.
FlushTimeout - Time budget for Terminate() to drain the queue and flush the SDK's transport buffer before the logger returns. The default is enough for normal shutdown; reduce it if your node has very strict shutdown deadlines.
SkipFrames - Top frames trimmed from captured panic stacks. The default matches the standard call chain. Increase if you wrap Log().Panic() in your own helpers, decrease if you call into the logger from a place closer to the panic.
BeforeSend - Hook forwarded to the Sentry SDK. Runs for every outgoing event. Return nil to drop, return the (possibly modified) event to forward. Useful for scrubbing sensitive fields or applying additional filters.
Register the sentry logger alongside your console or file logger in node options:
The default logger continues to write to stdout; the sentry logger receives the same stream and forwards the subset described above. To limit Sentry to just errors and panics without producing log work for the rest of the levels, register the logger with an explicit level filter:
For detailed logger configuration options, see the sentry.Options struct in the package. For understanding how loggers integrate with the framework, see .
What is a Node in Ergo Framework?
A node is the runtime environment where your actors live. Think of it as the container that hosts processes, routes messages between them, and handles the complexities of distributed communication.
When you start a node, you're launching a complete system with several subsystems working together: process management, message routing, networking, and logging. Each subsystem has a specific responsibility, and they coordinate to provide the foundation for your application.
Process Management - The node tracks every process running on it. When you spawn a process, the node assigns it a unique PID, registers it in the process table, and manages its lifecycle. When a process terminates, the node cleans up its resources and notifies any processes that were linked or monitoring it. The node provides ProcessRangeShortInfo for efficient iteration over all processes with their current state, including mailbox latency when built with -tags=latency.
Message Routing - When a process sends a message, the node figures out where it needs to go. Local process? Route it directly to the mailbox. Remote process? Establish a network connection if needed and send it there. The sender doesn't need to know these details.
Network Stack - The node handles all network communication. It discovers other nodes, establishes connections, encodes messages, and manages the complexity of distributed communication. This is what makes network transparency possible.
Pub/Sub System - Links, monitors, and events all work through a publisher/subscriber mechanism in the node core. When a process terminates or an event fires, the node knows who's subscribed and delivers the notifications. The node provides EventInfo to query statistics for a specific event and EventRangeInfo for callback-based iteration over all registered events with their per-event counters (messages published, local/remote deliveries).
Logging - Every log message goes through the node, which fans it out to registered loggers. This centralized logging makes it easy to capture, filter, and route log output.
A node needs a name. The format is name@hostname, where the hostname determines which network interface to use for incoming connections.
The name must be unique on the host. Two nodes with the same name can't run on the same machine, but nodes with different names can coexist.
The gen.NodeOptions parameter configures the node: which applications to start, environment variables, network settings, logging configuration. If you specify applications in the options, the node loads and starts them automatically. If any application fails to start, the entire node startup fails - this ensures you don't end up in a partially initialized state.
The node manages the complete process lifecycle.
When you spawn a process, the node creates it, registers it in the process table, calls its ProcessInit callback, and transitions it to the sleep state. The process is now live and can receive messages.
When the process terminates (either naturally or through an exit signal), the node calls ProcessTerminate, removes it from the process table, and notifies any processes that were linked or monitoring. Resources are cleaned up, and the gen.PID becomes invalid.
Processes can register names, making them addressable by name rather than PID. This is useful for well-known processes that other parts of the system need to find. The node maintains a name registry, ensuring each name maps to exactly one process.
Message routing is one of the node's core responsibilities.
When a process sends a message locally, the node simply places it in the recipient's mailbox. The recipient's goroutine wakes up (if it was sleeping), processes the message, and goes back to sleep if no more messages are waiting.
When the message goes to a remote process, things are more interesting. The node checks if a connection exists to the remote node. If not, it discovers the remote node's address (through the registrar or static routes) and establishes a connection. The message is encoded into the Ergo Data Format, optionally compressed, and sent over the network. The remote node receives it, decodes it, and delivers it to the recipient's mailbox.
From the sender's perspective, both paths look identical. That's network transparency.
Making remote message delivery work like local delivery requires solving three problems: finding remote nodes, establishing connections, and ensuring compatibility.
The first problem is discovery. When you send to a remote process, the node extracts which node that process belongs to from its identifier. Every node runs a small registrar service by default. For nodes on the same host, you query the local registrar. For nodes on different hosts, you query the registrar on that remote host - the framework derives the hostname from the node name and sends the query there. The registrar responds with connection information.
This default approach works for simple setups but has limitations. You're querying individual hosts, which requires them to be directly reachable. There's no cluster-wide view, no centralized configuration, no way to discover which applications are running where.
That's where etcd or Saturn come in. Instead of each node being its own island with a local registrar, you run a centralized registry service. All nodes register there when they start. All discovery queries go there. The central registrar becomes the source of truth for the cluster, providing not just discovery but configuration management, application tracking, and topology change notifications. It transforms independent nodes into a coordinated cluster.
Once a node is discovered, connections are established. Multiple TCP connections form a pool to that node, enabling parallel message delivery. The connections negotiate protocol details during handshake: which protocol version to use, whether compression is supported, what features are enabled. This negotiation allows nodes with different capabilities to work together.
Nodes have environment variables that all processes inherit. This provides a way to configure behavior without hardcoding values. A process can override inherited variables or add its own, creating a hierarchy: process environment overrides parent, which overrides leader, which overrides node.
Environment variables are case-insensitive. Whether you set "database_url" or "DATABASE_URL", the process sees the same value. This eliminates a common source of configuration bugs.
Stopping a node can be graceful or forced.
Graceful shutdown stops the running applications first, each one through its own Stop and Terminate callbacks, then sends exit signals to whatever processes are left. Processes receive gen.TerminateReasonShutdown and can save state, close connections, or send final messages before terminating, and the node waits for their ProcessTerminate callbacks to return rather than only for them to leave the process table. Once everything has stopped, the network stack shuts down, the loggers are closed, and the node exits.
Forced shutdown kills all processes immediately without waiting for cleanup. This is useful when you need to stop quickly, but processes don't get a chance to clean up properly.
One subtlety: if you call Stop from within a process, you create a deadlock. The process can't terminate because it's waiting for Stop to complete, but Stop is waiting for all processes (including this one) to terminate. The solution is either to call Stop in a separate goroutine or use StopForce, which doesn't wait.
Graceful shutdown can hang indefinitely if a process is stuck - perhaps blocked on a channel, waiting for an external resource, or caught in incorrect logic. To prevent this, the node has a shutdown timeout.
When the timeout expires, the node escalates: it force-kills any remaining processes and waits a short settle window (5 seconds) for them to unregister. If they still refuse to die, the node hard-exits with error code 1 after logging the surviving processes.
The default timeout is 3 minutes. You can change it globally through gen.NodeOptions:
or per call via StopWithTimeout, which overrides the node-level value for that invocation:
During shutdown, the node logs which processes are still running. Every 5 seconds, it prints a warning with the first 10 pending processes, showing their PID, registered name (if any), behavior type, state, and mailbox queue length. This diagnostic output helps identify what's blocking the shutdown:
The state tells you what the process is doing: running means it's handling a message, sleep means it's idle waiting for messages. The queue count shows how many messages are waiting. A process stuck in running with a growing queue indicates it's blocked in a callback and not processing its mailbox.
When the timeout fires, the same listing is printed at error level along with the force-kill notice, and the post-settle hard-exit report (if reached) shows whichever processes survived the kill so you can identify culprits that ignore termination signals.
Every node has a creation timestamp assigned when it starts. This timestamp is embedded in every gen.PID, gen.Ref, and gen.Alias that the node creates.
When two nodes connect, they exchange their creation timestamps during the handshake. Each connection stores the remote node's creation value.
Before sending any message to a remote process, the framework compares the target's Creation field against the stored creation of that remote node. If they differ, the operation returns gen.ErrProcessIncarnation immediately - no network message is sent.
This mechanism handles a common distributed systems problem: what happens when a remote node restarts? After restart, the node gets a new creation timestamp. Any gen.PID or gen.Alias from before the restart now contains the old creation value. When you try to send a message using that stale identifier, the framework detects the mismatch and returns an error instead of delivering the message to a wrong process.
The check applies to all remote operations: Send, Call, Link, Unlink, Monitor, Demonitor, SendExit, and SendResponse.
The node is infrastructure, not application logic. It provides the mechanisms - process management, message routing, networking - that your actors use to accomplish work.
This separation is important. Your actors focus on application logic: handling requests, processing data, managing state. The node handles the plumbing: routing messages, establishing connections, managing lifecycles. You don't write code to discover remote nodes or encode messages. The node does that.
This is what makes the framework approachable. You write actors that send and receive messages, and the node makes it all work, whether processes are local or distributed across a cluster.
The following chapters dive into specific node capabilities. explains the actor lifecycle and operations. covers distributed communication. explains how processes track each other.
WebWorker is a specialized actor for handling HTTP requests sent as meta.MessageWebRequest messages. It automatically routes requests to HTTP-method-specific callbacks and ensures the request completion signal is called.
Used with meta.WebHandler to convert HTTP requests into actor messages. See for integration approaches.
When WebHandler sends meta.MessageWebRequest to an actor, that actor must:
Extract the message from mailbox
Schedule tasks on a repetitive basis
Applications often need tasks to run periodically. Generate a daily report at midnight. Clean up expired sessions every hour. Send weekly summary emails. Poll an external API every five minutes.
You could implement this yourself - spawn a process that sleeps, wakes up, performs the task, and sleeps again. But then you're managing wake times, handling timezone changes, accounting for daylight saving time transitions, and ensuring the scheduler itself stays alive. The scheduling logic becomes scattered across your application.
Cron provides scheduled task execution as a framework service. You declare what should run and when using the familiar crontab syntax. The framework handles timing, execution, and all the edge cases around time-based scheduling.
Every minute, the cron system wakes up and evaluates all job specifications against the current time. Jobs whose specifications match the current minute are queued for execution. Each queued job then runs in its own goroutine.
This design is stateless - no pre-calculated schedules, no complex data structures to maintain. When you add a job, it participates in the next evaluation. When you remove a job, it stops participating. Timezone and daylight saving time transitions are handled naturally because each evaluation uses current time rules.
The stateless approach has implications. Multiple executions of the same job can run concurrently if the job takes longer than its interval. A job scheduled every minute that takes two minutes to complete will have two instances running simultaneously. If your job can't handle concurrent execution, implement serialization in the action itself - for example, send a message to a named process that processes requests sequentially.
Require certificate, don't verify against CA
tls.VerifyClientCertIfGiven
Verify against CA if provided
tls.RequireAndVerifyClientCert
Require and verify against CA
The construct is wrong often enough to look at, and legitimate sometimes.
3
off
Style and hygiene. Opt in when you want it.
opt
Panic
X
X
X
X
X
Error
X
X
X
opt
Determine HTTP method
Process the request
Write response to http.ResponseWriter
Call Done() to unblock the waiting HTTP handler
WebWorker automates this. Embed it, implement method-specific callbacks, and the framework handles routing and cleanup.
Embed act.WebWorker and implement callbacks for HTTP methods you handle:
Spawn worker with registered name:
When HTTP request arrives:
WebHandler sends meta.MessageWebRequest to "api-worker"
WebWorker detects message type, extracts HTTP method
WebWorker calls appropriate Handle* method
Your callback processes request, writes response
WebWorker calls Done() automatically
HTTP handler unblocks, response sent to client
All callbacks are optional. Implement only the methods you need:
HTTP methods:
HandleGet(from gen.PID, writer http.ResponseWriter, request *http.Request) error
HandlePost(from gen.PID, writer http.ResponseWriter, request *http.Request) error
HandlePut(from gen.PID, writer http.ResponseWriter, request *http.Request) error
HandlePatch(from gen.PID, writer http.ResponseWriter, request *http.Request) error
HandleDelete(from gen.PID, writer http.ResponseWriter, request *http.Request) error
HandleHead(from gen.PID, writer http.ResponseWriter, request *http.Request) error
HandleOptions(from gen.PID, writer http.ResponseWriter, request *http.Request) error
Actor callbacks:
Init(args ...any) error - initialization
HandleMessage(from gen.PID, message any) error - non-HTTP messages
HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) - synchronous requests
HandleEvent(message gen.MessageEvent) error - event subscriptions
Terminate(reason error) - cleanup
HandleInspect(from gen.PID, item ...string) map[string]string - introspection
Unimplemented HTTP methods return 501 Not Implemented automatically.
Return nil to continue processing requests. Return non-nil error to terminate the worker:
Returning error terminates the worker. Use this for fatal errors only (database connection lost, critical resource unavailable). For transient errors (validation, not found, conflict), write error response and return nil.
Single worker processes one request at a time. Use act.Pool for concurrent processing:
Spawn pool instead of single worker:
WebHandler sends requests to pool. Pool distributes across 10 workers. System handles 10 concurrent requests.
For details on pools, see Pool.
WebWorker processes meta.MessageWebRequest specially, but also receives regular messages:
This allows workers to receive configuration updates, control messages, or other actor communication while processing HTTP requests.
WebWorker implements gen.ProcessBehavior at low level. It manages the mailbox loop, detects meta.MessageWebRequest, routes by HTTP method, and calls Done() after processing.
The Done() call is critical. It cancels the context that WebHandler blocks on. Without it, HTTP request would timeout. WebWorker guarantees Done() is called even if your callback panics or returns error.
Default implementations for all callbacks exist. Unimplemented HTTP methods log warning and return 501 Not Implemented. This allows implementing only the methods you need without boilerplate for unsupported methods.
package main
import (
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/logger/sentry"
)
func main() {
sl, err := sentry.CreateLogger(sentry.Options{
DSN: "https://<key>@sentry.io/<project>",
Environment: "production",
Release: "myapp@1.2.3",
})
if err != nil {
panic(err)
}
options := gen.NodeOptions{}
options.Log.Loggers = []gen.Logger{
{Name: "sentry", Logger: sl},
}
node, err := ergo.StartNode("demo@localhost", options)
if err != nil {
panic(err)
}
node.Wait()
}node.LoggerAdd("sentry", sl, gen.LogLevelError, gen.LogLevelPanic)node, err := ergo.StartNode("myapp@localhost", gen.NodeOptions{})
if err != nil {
panic(err)
}
defer node.Wait()options := gen.NodeOptions{
ShutdownTimeout: 30 * time.Second,
}node.StopWithTimeout(10 * time.Second)[warning] node 'myapp@localhost' is still waiting for process(es) to terminate:
[warning] <ABC123.0.1004> ('worker_1', main.Worker) state: running, queue: 1
[warning] <ABC123.0.1005> ('worker_2', main.Worker) state: running, queue: 0
[warning] <ABC123.0.1006> (main.Worker) state: running, queue: 5type APIWorker struct {
act.WebWorker
}
func (w *APIWorker) HandleGet(from gen.PID, writer http.ResponseWriter, request *http.Request) error {
// Process GET request
user := w.lookupUser(request.URL.Query().Get("id"))
json.NewEncoder(writer).Encode(user)
return nil
}
func (w *APIWorker) HandlePost(from gen.PID, writer http.ResponseWriter, request *http.Request) error {
// Process POST request
var data CreateRequest
json.NewDecoder(request.Body).Decode(&data)
result := w.createResource(data)
writer.WriteHeader(http.StatusCreated)
json.NewEncoder(writer).Encode(result)
return nil
}
func (w *APIWorker) HandleDelete(from gen.PID, writer http.ResponseWriter, request *http.Request) error {
id := request.URL.Query().Get("id")
w.deleteResource(id)
writer.WriteHeader(http.StatusNoContent)
return nil
}type WebService struct {
act.Actor
}
func (s *WebService) Init(args ...any) error {
// Spawn worker
_, err := s.SpawnRegister("api-worker",
func() gen.ProcessBehavior { return &APIWorker{} },
gen.ProcessOptions{},
)
if err != nil {
return err
}
// Create WebHandler pointing to worker
handler := meta.CreateWebHandler(meta.WebHandlerOptions{
Worker: "api-worker",
})
_, err = s.SpawnMeta(handler, gen.MetaOptions{})
// rest of setup...
}func (w *APIWorker) HandlePost(from gen.PID, writer http.ResponseWriter, request *http.Request) error {
var data CreateRequest
if err := json.NewDecoder(request.Body).Decode(&data); err != nil {
// Invalid JSON - return error to client, continue processing
http.Error(writer, "Invalid JSON", http.StatusBadRequest)
return nil
}
if err := w.createResource(data); err != nil {
// Transient error - return error to client, continue processing
http.Error(writer, "Create failed", http.StatusInternalServerError)
return nil
}
writer.WriteHeader(http.StatusCreated)
return nil
}type APIWorkerPool struct {
act.Pool
}
func (p *APIWorkerPool) Init(args ...any) (act.PoolOptions, error) {
return act.PoolOptions{
PoolSize: 10,
WorkerMailboxSize: 20,
WorkerFactory: func() gen.ProcessBehavior { return &APIWorker{} },
}, nil
}_, err := s.SpawnRegister("api-worker",
func() gen.ProcessBehavior { return &APIWorkerPool{} },
gen.ProcessOptions{},
)func (w *APIWorker) HandleMessage(from gen.PID, message any) error {
// meta.MessageWebRequest handled automatically by WebWorker
// Other messages reach this callback
switch m := message.(type) {
case ConfigUpdate:
w.config = m.Config
w.Log().Info("Configuration updated")
}
return nil
}A job specification declares what should run and when:
CreateCronActionMessage is generic over gen.Atom | gen.ProcessID | gen.PID | gen.Alias, and a bare "reporter" infers as string, which is not in that set. Write the conversion out: gen.Atom("reporter").
The Name identifies the job uniquely within the node. The Spec uses crontab format to define the schedule. The Location specifies which timezone to use when interpreting the schedule. The Action defines what happens when the schedule triggers.
Optionally, Fallback names a process to notify when the action returns an error, which gives scheduled tasks one place to handle failures:
Enable is the part that is easy to miss. The field is a gen.ProcessFallback borrowed from mailbox-overflow configuration, and the cron path returns early unless Enable is true - so filling in Name alone gives you silence, with the failure visible only as an error line in the log. The notified process receives gen.MessageCronFallback{Job, Tag, Time, Err}, and if it is unreachable that is logged too.
Actions define what happens when a job runs.
The simplest action sends a message. The job triggers, the cron system sends gen.MessageCron to the specified process, and the process handles it through normal message processing. This integrates cleanly with the actor model - the scheduled work happens inside an actor's message handler.
For work that needs isolation per execution, spawn a process. Each time the job triggers, a fresh process spawns, performs the work, and terminates. If one execution crashes, the next starts clean. The spawned process receives environment variables identifying which job spawned it and when (gen.CronEnvNodeName, gen.CronEnvJobName, gen.CronEnvJobActionTime).
For distributed systems, spawn on a remote node. A job on the coordinator can trigger work on data nodes. The remote node must have enabled spawn permissions for the process name. This pattern centralizes scheduling while distributing execution.
Custom actions implement the gen.CronAction interface. The Do method receives the job name, node reference, and execution time in the job's timezone. Return an error to trigger fallback handling.
Five fields, in the crontab order: minute, hour, day-of-month, month, day-of-week. Close to standard crontab, with two deliberate differences worth knowing before you write a spec.
Day-of-week runs 1 to 7, and Sunday is 7. A 0 is rejected - "* * * * 0" fails with incorrect value: 0. Matching remaps the runtime's Sunday to 7, so 7 is how you write it.
Step syntax is not accepted in the day-of-week field. */15 * * * * is fine in the minute field; 0 0 * * */2 is not, and fails with incorrect value: */2. That field takes *, a number, a range d-d, dL for the last such weekday of the month, or d#n for the n-th.
Common patterns:
0 * * * * - Every hour
0 0 * * * - Every day at midnight
*/15 * * * * - Every 15 minutes
0 9-17 * * 1-5 - Every hour from 9-5 on weekdays
0 0 1 * * - First day of each month
0 0 * * 5#2 - Second Friday of each month
0 0 L * * - Last day of each month
0 0 * * 7 - Every Sunday
Four macros are recognised, and they are not on the hour - they carry a deliberate offset so that unrelated jobs do not all fire on the same tick:
@hourly
1 * * * *
at minute 1 of every hour
@daily
10 3 * * *
There is no @yearly, @annually, @midnight or @reboot: those fail with incorrect cron spec format. Write the five fields out if you need midnight exactly.
Jobs can be defined at node startup in gen.NodeOptions.Cron.Jobs, or managed dynamically through the gen.Cron interface.
Add jobs with AddJob. Remove them with RemoveJob. Temporarily disable with DisableJob (useful for maintenance windows), and resume with EnableJob. Query status with Info and JobInfo, which show execution history and errors.
The Schedule and JobSchedule methods preview upcoming executions. Since the implementation evaluates specifications on-demand rather than maintaining pre-calculated schedules, these methods perform the same evaluation logic for a future time range. Use them to verify your crontab specs are correct or to detect scheduling conflicts.
Each job has its own timezone. A job with Location: time.UTC scheduled for midnight runs at UTC midnight. A job with a New York timezone runs at New York midnight. The physical location of the node doesn't matter - jobs run in their configured timezone.
This matters for distributed systems where jobs serve different regions. One node can run jobs for multiple timezones. A cleanup job for European users runs at European midnight. A report job for Asian users runs at Asian business hours. Same node, different timezones, correct local timing.
Timezone transitions are handled carefully.
When clocks spring forward, an hour disappears. A job scheduled for 02:30 simply does not run on that date: the spec is evaluated against local time, and that local minute never occurs, so nothing matches. It is skipped rather than run an hour early or late.
Separately from the transitions, the scheduler checks that the minute it is firing for is really the current minute, and drops the job if the clock moved between scheduling and firing - an NTP step or a suspended process, rather than a timezone rule.
When clocks fall back, an hour repeats, and a job scheduled inside it matches on both passes - 30 1 * * * in America/New_York matches at 01:30 EDT and again an hour later at 01:30 EST. It runs once: before running, the scheduler compares the action's local wall clock minute against the last one it ran, and a repeat is skipped. Comparing instants would not catch this, since the two passes are a genuine hour apart.
Cron().JobSchedule(name, since, period) previews the same decision, so a transition day reports one run rather than two, matching what the scheduler will actually do.
The action can still tell where it is in time: a message action receives gen.MessageCron with a Time field, and a spawn action gets the same instant in its environment as gen.CronEnvJobActionTime.
If a job action returns an error and the job has a configured fallback, the system sends gen.MessageCronFallback to the fallback process. The message includes the job name, execution time, error, and an optional tag for identifying the job source.
This allows centralizing monitoring of failed scheduled tasks. A single fallback process can receive failures from all jobs, log them, send alerts, or take corrective action.
For complete crontab specification syntax and additional examples, refer to the gen.Cron interface documentation in the code.
job := gen.CronJob{
Name: "daily_report",
Spec: "0 0 * * *",
Location: time.UTC,
Action: gen.CreateCronActionMessage(gen.Atom("reporter"), gen.MessagePriorityNormal),
}Fallback: gen.ProcessFallback{
Enable: true, // required: without it nothing is sent
Name: "cron_supervisor",
Tag: "daily_report",
},action := gen.CreateCronActionMessage(gen.Atom("worker"), gen.MessagePriorityNormal)action := gen.CreateCronActionSpawn(createReportWorker, gen.CronActionSpawnOptions{})action := gen.CreateCronActionRemoteSpawn("worker@datanode", "report_worker", gen.CronActionSpawnOptions{})What is a Process in Ergo Framework
A process is an actor - a lightweight entity that handles messages sequentially in its own goroutine. It's the fundamental building block of an Ergo application.
Every process has a mailbox where incoming messages wait to be processed. The mailbox contains four queues with different priorities: Urgent for critical system messages, System for framework control, Main for regular application messages, and Log for logging. When the process wakes up to handle messages, it processes them in priority order, taking from Urgent first, then System, then Main, and finally Log.
When built with -tags=latency, each queue tracks the age of its oldest unprocessed message. ProcessMailbox.Latency() returns the maximum latency across all four queues in nanoseconds, or -1 if the tag is not enabled. This helps identify processes that are falling behind on message processing. See for details.
The process runs only when it has messages to handle. When the mailbox is empty, the process sleeps, consuming no CPU. When a message arrives, the process wakes, handles the message, and sleeps again if nothing else is waiting. This efficiency is why you can have thousands of processes in a single application.
A process identifier (gen.PID
Standalone fakes of the gen interfaces, for testing code that consumes them
Not all code that touches the framework is an actor. A helper that takes a gen.Process to make a call, a custom resolver that implements gen.Resolver, a constructor that reads configuration from a gen.Node - these are ordinary functions, and you test them the ordinary way: give them a dependency you control, run them, and check what happened. mock provides that dependency. For each of the framework's interfaces it offers a standalone fake you can hand to the code under test in place of the real thing.
These fakes are deliberately dumb, and it helps to say plainly what they are not: a mock is not the harness in disguise. It runs no actor, starts no goroutine, and never fails your test on its own. It implements an interface, lets you override the methods you care about, and returns safe defaults for the rest. Each example names the type doing the work, so it is always clear which mock you are looking at.
mock.NewProcess returns a value that satisfies
The shared assertion vocabulary that unit and stage are written in
You cannot test an actor the way you test a function. There is no result := actor.Handle(msg) to inspect: an actor runs on its own goroutine, keeps private state, and speaks only in messages. What you can see is what it does - the messages it sends, the children it spawns, the way it terminates. So the testing tools watch the thing under test and record every such action, and a test then asks questions of that recording: did it send this, how many times, to whom, and after what.
check is the language those questions are written in. It is not a harness you run on its own; it is the shared vocabulary - the record types and the assertion grammar - that both and hand you. You will rarely import it directly. You call its assertions on a unit Subject or a stage Node, and because both layers expose the very same grammar, learning it once here lets you assert anything in either of them. The examples below use a handle - sub for a unit subject, node
03:10 every day
@weekly
30 5 * * 1
Monday 05:30
@monthly
20 4 1 * *
the 1st at 04:20
The creation timestamp is the node's startup time. If a node restarts, the creation value changes, which means PIDs from before the restart are distinguishable from PIDs after. If you try to send a message to a gen.PID with an old creation value, you get an error. This prevents messages from being delivered to the wrong process after a node restart.
Besides PIDs, processes can be identified by registered names. A process can register one name, making it addressable as gen.ProcessID{Name: "worker", Node: "node@host"}. This is useful for well-known processes that other parts of the system need to find without knowing their gen.PID.
Processes can also create aliases - temporary identifiers that provide additional addressing options. Unlike registered names (one per process), a process can create unlimited aliases using gen.Alias. They're useful when you need multiple ways to address the same process, such as in request-response patterns or when implementing services with multiple endpoints.
A process goes through several states during its lifetime.
It starts in Init, where the ProcessInit callback runs. In this state, the process can spawn children, send messages, register names, create aliases, register events, establish links and monitors, and make synchronous calls.
After initialization succeeds, the process enters Sleep and is ready to receive messages. When a message arrives, the process transitions to Running, handles the message, and returns to Sleep.
If the process makes a synchronous call, it enters WaitResponse while waiting for the reply. Once the response arrives, it returns to Running and continues processing.
Eventually the process terminates. This can happen in several ways: it returns an error from its message handler, it receives an exit signal, the node kills it, or a panic occurs. The process is removed from the node first, so everything linked to or monitoring it is notified, and then the ProcessTerminate callback runs for cleanup. The process is fully gone once that callback returns.
You spawn processes through a factory function that creates instances of your actor.
The factory is called each time you spawn - each process gets a fresh instance. This isolation is important for the actor model.
gen.ProcessOptions configures the new process: mailbox size, environment variables, compression settings, message priority, linking behavior, and initialization timeout. Most options have sensible defaults. The main ones you'll configure are MailboxSize (to limit memory) and Env (to pass configuration).
InitTimeout limits how long ProcessInit can take. Zero uses the default (5 seconds). If initialization exceeds this timeout, the process is terminated with gen.ErrTimeout and spawn returns an error. For remote spawn and application processes, the maximum allowed value is 15 seconds - exceeding this limit returns gen.ErrNotAllowed.
Two options deserve explanation: LinkParent and LinkChild. These options provide a convenient way to establish links automatically after initialization completes. If LinkChild is set, the parent links to the child. If LinkParent is set, the child links to the parent. These links only work for process-spawned children, not node-spawned processes. Note that you can also call Link methods directly during initialization if needed.
Processes are defined by implementing the gen.ProcessBehavior interface. This is a low-level interface with four methods: ProcessInit for initialization, ProcessRun for the message processing loop, ProcessTerminate for cleanup, and ProcessKind, which classifies the process and is called unconditionally at spawn. All four are required - a type implementing only the first three does not satisfy the interface.
In practice, you rarely implement gen.ProcessBehavior directly. Instead, you use act.Actor, which implements gen.ProcessBehavior and provides a more convenient abstraction. act.Actor gives you HandleMessage and HandleCall callbacks - straightforward methods where you write your message handling logic without worrying about the mailbox mechanics.
The ProcessInit callback runs once during startup. Use it to initialize state, spawn children, configure properties. If it returns an error or exceeds the InitTimeout, the process is cleaned up and removed - it terminates immediately.
The ProcessTerminate callback runs during shutdown. Use it for cleanup: close files, send final messages, log termination. It receives the termination reason, so you can distinguish between normal shutdown and errors.
act.Actor handles the ProcessRun loop for you, calling your HandleMessage and HandleCall methods as messages arrive. This separation between the low-level interface (gen.ProcessBehavior) and the high-level abstraction (act.Actor) keeps the framework flexible while making common cases simple.
Sometimes a process needs to act later, or on a schedule, rather than in response to an incoming message. The wrong way is to start a time.Timer or time.Ticker inside a callback: it fires on its own goroutine, outside the mailbox, and touching process state from there breaks the single-threaded guarantee.
Instead, let the process message itself. SendAfter delivers a message to a target once after a delay; SendEvery delivers it repeatedly on a fixed period, reusing a single timer so it does not allocate on each tick. Both return a cancel function, and both route the message through the mailbox - it is handled in HandleMessage like any other, on the process's own goroutine and in order. The delivery options (priority, compression, network order) are captured when you schedule, not re-read on each fire.
A SendEvery ticker lives as long as its owner: it stops when the process terminates or when you call its cancel function. A failed tick - the target is busy or already gone - does not stop it; transient failures are retried on the next period, and if you need to know that the target died, monitor it rather than infer it from a gap in ticks.
Processes inherit environment variables when they spawn. At that moment, variables are copied from multiple sources and merged with a priority order: node variables (lowest priority), then application, then leader, then parent, then variables specified in gen.ProcessOptions (highest priority). If the same variable exists in multiple sources, the higher priority value wins.
Once a process is running, its environment is independent. If the node changes an environment variable, running processes don't see the change. Only newly spawned processes inherit the updated values. This isolation is important - it means a process's configuration is stable for its lifetime.
When a process queries a variable with Env or EnvList, it looks only in its own environment - the merged copy created at spawn time. The hierarchy (Process > Parent > Leader > Application > Node) determines what was copied during spawning, not what's queried during lookup.
Variables are case-insensitive. "database_url", "DATABASE_URL", and "Database_Url" are all the same variable. This eliminates configuration mistakes from case mismatches.
Use SetEnv to modify variables during Init or Running states. Pass nil as the value to delete a variable. Changes affect only this process - they don't propagate to children, parents, or the node.
Processes typically terminate themselves by returning an error from ProcessRun. In act.Actor, this manifests as returning an error from HandleMessage, HandleCall, or other handler callbacks. Return gen.TerminateReasonNormal for clean shutdown, or any other error to indicate why termination occurred. The process transitions to Terminated, is removed from the node, and then runs its ProcessTerminate callback for cleanup.
If a panic occurs during message handling, the framework catches it and terminates the process with gen.TerminateReasonPanic. The ProcessTerminate callback still runs, giving the process a chance to clean up despite the panic. What is logged is the panic value plus the single frame the recovery saw, not a stack trace - see Actors for what to do when one frame is not enough.
When a process dies, its mailbox is discarded by default. Anything queued but unprocessed is lost. For short-lived or one-shot processes this is fine. For continuously working actors it can be very painful:
A task worker pulling from an external queue panics on one bad task. The 50 other queued tasks in its mailbox evaporate. They have to be re-fetched from upstream if upstream still has them, or they are gone.
A per-session actor (chat user, IoT device, game session) crashes during a code push. Messages buffered for it between crash and restart vanish.
A batch aggregator collecting events for a periodic flush dies mid-batch. The unflushed accumulator is gone with no record of what was inside.
A sequencer enforcing ordering across multiple sources crashes mid-stream. The reorder buffer is lost.
Mailbox preservation gives an "actor restarts but keeps its inbox" semantic. It turns the default behavior from at-most-once for queued messages to at-least-once. Enable it with one flag at spawn:
With the flag set, any abnormal termination (panic, callback error, forced Kill, exit cascade from a linked process) captures the mailbox into a *gen.Error exit reason. The original error is preserved as Wrapped[0], so errors.Is(reason, originalErr) keeps working. A supervising parent automatically picks it up and hands it to the restart. The new incarnation runs Init on fresh struct state, then its ProcessRun loop pulls the surviving messages from the queues in priority order, exactly as if they had just arrived.
This is not a replacement for an external durable queue (Kafka, NATS, etc.). External queues give reliable delivery into the actor; mailbox preservation gives reliable handling within the actor across its own restarts. They complement each other.
What is preserved. All four priority queues (Urgent, System, Main, Log), in original FIFO order, with their tracing information.
What is not preserved.
The triggering message (the one the actor was processing when it failed). Replaying it would create a panic-restart-panic loop on poison-pill inputs, so the framework deliberately drops it. Upstream must replay if needed.
The actor's struct fields. In-process state lives only as long as the incarnation does; the new instance runs Init from scratch.
Outgoing Calls in flight. The ref is tied to the dead PID; responses arriving after death are dropped.
Normal exits skip capture. gen.TerminateReasonNormal and gen.TerminateReasonShutdown mean the actor itself decided to stop, and re-feeding its queue would contradict that decision.
At-least-once caveat. Because the surviving messages are re-delivered to a new instance, partially completed side effects from the dead incarnation can be re-attempted by the new one. If your handler is not idempotent (or doesn't deduplicate by some message id), you can end up with duplicate side effects. This is the standard at-least-once trade-off: you trade duplicates for losses.
Same-node only. A live mailbox does not cross the network: the Mailbox field on gen.Error is excluded from EDF wire encoding. Remote spawns and remote exit signals see it as nil. Mailbox preservation is a local feature.
When to enable. Task workers, per-request/per-connection handlers, per-session actors, aggregators. Anywhere unprocessed messages in queue represent unfinished work that must not silently disappear.
When not to enable. Strongly stateful actors with non-idempotent side effects that don't deduplicate. Memory-constrained scenarios where a large preserved mailbox would pin too much memory between crash and restart. One-shot init scripts that must restart from a clean state.
A common production setup is PreserveMailbox: true together with a per-spec or per-instance restart budget and OnExceedDisable, so a poison-pill input cannot loop the worker through its budget and take the supervisor down. See Mailbox Preservation Across Restart on the Supervisor page for the full supervisor-side contract, validation rules, and a worked example.
Processes can also be terminated externally. Sending an exit signal with SendExit delivers a high-priority termination request to the process's Urgent queue. Actors can trap these signals and handle them as regular messages, allowing graceful shutdown. This is how supervision trees restart workers - send an exit signal, wait for clean termination, then spawn a replacement.
The most forceful option is Kill. If the process is idle (Sleep state), it transitions directly to Terminated and ProcessTerminate is called. If the process is actively handling a message (Running or WaitResponse states), it's marked as Zombee. In Zombee state, all operations return gen.ErrNotAllowed. The process finishes its current message, then terminates and calls ProcessTerminate. Use Kill when you need to stop a process that isn't responding to exit signals.
Regardless of how termination happens, the node performs comprehensive cleanup. Events the process registered are unregistered. Its registered name becomes available for reuse. Aliases are deleted. Links and monitors are removed. If the process was acting as a logger, it's removed from the logging system. Meta processes spawned by this process are terminated. This ensures no dangling references remain after a process is gone.
Not all Process interface methods work in all states. This isn't arbitrary - it reflects what's actually possible.
During Init, the process can spawn children, send messages, register names, create aliases, register events, establish links and monitors, and make synchronous calls.
During Running, everything is available. The process is fully operational.
During Terminated, only sending messages works. You can't spawn new children or create new resources - the process is shutting down.
These restrictions are enforced by the framework. If you call a method in the wrong state, you get gen.ErrNotAllowed. This prevents subtle bugs where operations appear to succeed but silently fail because the process isn't in the right state.
The details of which methods work in which states are documented in the gen.Process godoc. In practice, you rarely hit these restrictions unless you're doing unusual things during initialization or shutdown.
For a deeper understanding of process operations and lifecycle management, refer to the gen.Process interface documentation in the code.
type Worker struct {
act.Actor
}
func createWorker() gen.ProcessBehavior {
return &Worker{}
}
pid, err := node.Spawn(createWorker, gen.ProcessOptions{})type Worker struct {
act.Actor
}
func (w *Worker) HandleMessage(from gen.PID, message any) error {
// Handle the message
// Return nil to continue, return error to terminate
return nil
}node.Spawn(createWorker, gen.ProcessOptions{
PreserveMailbox: true,
})gen.ProcesssaveUser holds db as a gen.Process and cannot tell it is a fake. Every method has a matching On<Method> setter - OnCall, OnSend, OnSpawn, OnLink, across the whole interface - so you configure exactly the surface your test exercises and leave the rest alone.
A method you do not override still works; it just returns a safe default. A query returns a zero value, an action reports success, and anything that must produce an identifier returns a synthesized one - a stable gen.PID, gen.Alias, or gen.Ref. Nothing panics, and nothing fails the test.
This is the deliberate difference from unit, and it is worth understanding because it tells you which tool you are holding. The unit harness fails loudly when an actor takes an action you did not set up, because there the unexpected action is the bug under test. A mock makes no such judgment: it is a dependency you are injecting, not the subject of the test, so an unconfigured call is simply a no-op with a sensible result.
Sometimes the return value is not what you care about - you want to verify what the code did with the dependency: that it spawned three workers, that it logged the failure. For that, every mock type has a second constructor whose name ends in T and which takes the test's testing.T. It behaves exactly like the dumb one, and on top of that records every action and exposes the check assertion grammar on the mock.
So each type comes as a pair: mock.NewNode is the dumb form, mock.NewNodeT(t) the recording one; likewise NewProcess / NewProcessT, NewLog / NewLogT, and the rest. Because the recording mock carries the grammar from check, the whole vocabulary - ShouldSend, filters, cardinalities, Capture - is available on it.
The two features compose, and in the order you would want. On a recording mock an override decides the return value while the action is still recorded - the override shapes what the call returns, the recorder simply notes that it happened:
The override runs first, because it is the behavior; the record is taken afterward. This mirrors stubbing in unit: setting a return value never hides the action from assertions.
Some interfaces hand back others: a gen.Node exposes a gen.Log, a gen.Network, and a gen.Cron; a gen.Network exposes a gen.Registrar, which in turn exposes a gen.Resolver. You do two separate things with that, and keeping them apart is what keeps it simple.
The first is reading them, and it comes for free. A recording mock wires the sub-mocks it owns to share its recorder, so whatever they do collates into one journal. You use them exactly as on a real node and assert through the parent - here the node's own logger lands in the node's journal:
The second is steering them: making one of those returned interfaces behave a certain way - say, a resolver that fails. You do not reach down into the parent's sub-mock; you build your own from the bottom up and wire it in with the parent's On* override. Each mock is configured on the concrete value you hold, so every On* is right there, and nothing needs a type assertion:
Now code that calls net.Registrar() receives reg, and reg.Resolver() receives the failing resolver - the same chain the code walks, assembled the way you would assemble the real one.
There is one mock per interface, each with the dumb and recording constructor pair:
NewNode / NewNodeT
gen.Node
NewProcess / NewProcessT
gen.Process
NewMeta / NewMetaT
The last three are for testing the framework's own internals rather than application code - a custom gen.NetworkProto, or anything that routes through the core. Reach for them when you are writing a protocol, not a service.
Use a mock when the thing under test is not an actor but consumes one of the framework interfaces: a function that takes a gen.Process or gen.Node, a custom gen.Resolver or gen.Registrar, a constructor that reads from a node. When the thing under test is an actor - a behavior whose Init, HandleMessage, and HandleCall you want to drive - use unit, which builds its own controllable node around the actor and adds typed input drivers on top. The two share the check grammar, so what you learn asserting on a recording mock transfers directly.
func saveUser(db gen.Process, u User) error {
_, err := db.Call(gen.Atom("users"), Insert{Name: u.Name})
return err
}
func TestSaveUser(t *testing.T) {
db := mock.NewProcess()
db.OnCall(func(to, request any) (any, error) { return Row{ID: 7}, nil })
err := saveUser(db, User{Name: "ann"})
check.NoError(t, err)
}db := mock.NewProcess()
db.OnCall(func(to, request any) (any, error) { return Row{ID: 7}, nil })
check.NoError(t, db.Send(gen.Atom("audit"), "saved")) // Send was never overridden; it just succeedsfunc TestBootstrap(t *testing.T) {
node := mock.NewNodeT(t)
bootstrap(node) // the code under test calls node.Spawn three times
node.ShouldSpawn().Times(3).Assert()
}p := mock.NewProcessT(t)
p.OnSend(func(to, message any) error { return gen.ErrProcessUnknown })
err := p.Send(gen.Atom("dead"), "hi")
check.ErrorIs(t, err, gen.ErrProcessUnknown) // the override decided this
p.ShouldSend().To(gen.Atom("dead")).Once().Assert() // and it was still recordedn := mock.NewNodeT(t)
n.Send(gen.Atom("peer"), "ping")
n.Log().Info("started %d workers", 3)
n.ShouldSend().To(gen.Atom("peer")).Message("ping").Once().Assert()
n.ShouldLog().Containing("started 3 workers").Once().Assert()resolver := mock.NewResolver()
resolver.OnResolve(func(node gen.Atom) ([]gen.Route, error) { return nil, gen.ErrNoRoute })
reg := mock.NewRegistrar()
reg.OnResolver(func() gen.Resolver { return resolver })
net := mock.NewNetwork()
net.OnRegistrar(func() (gen.Registrar, error) { return reg, nil })Begin with what is being queried, because every assertion is a query against it. As the thing under test runs, the harness appends each action it observes to an ordered journal, and each entry is a typed record. You never build a record - the harness does - and you never read the journal line by line. But picturing it is what makes the grammar make sense.
A journal captured while one actor handled a single message might read like this:
The entries are different types because they describe different kinds of happening, and which kinds turn up depends on what the actor did and what produced them. They fall into three groups:
Egress - what the actor does: Send, Call, Spawn, Link, Monitor, Log, and its other outgoing actions.
Lifecycle - Terminated, the actor's own end.
Ingress - what reaches the actor: Delivered, Down, Exit, Event. There is something to record on the way in only where delivery is real, so these appear in , not in unit.
Each record carries fields that describe the action - a Send has From, To, Message, Options, Error; a Spawn has Parent, Child, Register, Factory, Error. You match on those fields rather than scanning text. The complete list of record types and their fields is in the package godoc; in practice you meet each one through the assertion that selects it, which is the next thing.
An assertion is a single chain with four parts: choose a record type, narrow it with filters, say how many should match, and run it.
Read left to right: of the recorded Send actions (ShouldSend), the ones addressed to "db" and carrying that message (the two filters), there should be exactly one (Once); evaluate it now and report a failure if not (Assert). There is one Should... builder per record type - ShouldSend, ShouldSpawn, ShouldCall, ShouldLog, and so on - and everything that follows on this page is a variation of one of those four parts. Learn the chain and you can read any assertion in the framework.
The third part answers "how many," and there are four ways to answer:
None is how you assert a negative - that an action did not happen - so there is no separate "should not" builder; you expect a count of zero.
The filters are named after the record's fields, and they reach well past To and Message. You narrow on whatever distinguishes the action you mean:
Where an action can fail, Error matches an exact error and ErrorIs matches a wrapped one:
And when no named filter fits - you need one field of a struct, a range, a computed condition - Where takes a typed predicate over the record itself:
Filters compose: chain as many as you need, and a record must satisfy all of them to match.
Because the chain is uniform, the assertions you have not met yet read exactly like the ones you have. Termination is its own record, asserted with ShouldTerminate, which adds a small vocabulary for the reason:
On a live node the ingress records become assertions too - ShouldDeliver for a message that arrived, ShouldReceiveDown and ShouldReceiveExit for the notifications a monitor or link delivers, ShouldReceiveEvent for a subscribed event. They read with the same chain, with filters for their own fields (About and Reason on a down, for instance), and they belong to stage, where delivery is real:
That Within is new; it is the next idea.
Whether an assertion waits depends on the layer, and this is the one real difference between using check in unit and in stage. In unit the actor has already run to completion by the time you assert, so the journal is final - the assertion reads a snapshot and returns at once. In stage real actors run concurrently, so the action you expect may not be recorded yet. Within turns the terminal into a bounded wait that polls until the assertion holds or the deadline passes:
A positive assertion is satisfied the moment its count is met. A negative is the case to think about: to claim something did not happen, you have to watch for a while, so None().Within(...) passes only if nothing matched for the whole window. One sharp edge with an exact count: Within is met at the first poll where the count equals n, so if a still-growing count overshoots n between two polls it never reads as n again and the assertion fails at the deadline - for a count that only grows, use AtLeast rather than Times.
A test often runs in stages, and an earlier stage may have produced the same kind of record you now want to count. Mark records the current position in the journal; Since restricts the next assertion to what came after it:
This is also how you express "no second occurrence after a legitimate first one": mark past the first, then assert None().Since(mark).
Actors produce values you cannot know in advance - a spawned child's PID, an allocated alias. Capture returns the first matching record so you can read those values and use them later in the test:
Collect returns every matching record in the order observed, which is what you want when the order itself is under test - a round-robin distribution, say:
(Records returns the whole journal as a slice, for poking at it while you debug a test; the assertions themselves should use the grammar.)
Both Assert and Must evaluate the same way; they differ in what a failure does. Assert reports it and lets the test continue. Must stops the test immediately - reach for it when later steps cannot run meaningfully without this one, so the log shows the real cause instead of a cascade of follow-on failures.
Not everything in a test is a record. You still inspect returned errors and compare captured values, and check carries plain helpers for that, so a test needs no second assertion library:
The set also includes False, NotEqual, Nil, NotNil, Error, Contains, and ErrorContains.
One last piece of vocabulary shows up not in assertions but in the stubbing APIs of mock and unit, where you tell a dependency how to answer a particular call. A small set of matchers narrows a stub to the calls it should handle: Anything, Equals, MatchedBy, and IsType.
IsType[V] matches a value assignable to V: a concrete type matches its exact dynamic type, an interface matches any value that implements it. You will see these in context on the next pages.
That is the whole language. What produces the journals it queries - and the inputs you drive to fill them - are mock, unit, and stage.
Spawn(parent=svc child=worker register=worker err=<nil>)
Send(from=svc to=worker msg=Job{ID:"42"} err=<nil>)
Log(from=svc level=info msg="dispatched job 42")
Call(from=svc to=registry req=Lookup{Name:"db"} err=<nil>)sub.ShouldSend().To(gen.Atom("db")).Message(SaveUser{ID: 7}).Once().Assert()sub.ShouldSpawn().Once().Assert() // exactly one
sub.ShouldSpawn().Times(3).Assert() // exactly three
sub.ShouldSend().To(gen.Atom("metrics")).AtLeast(1).Assert() // one or more
sub.ShouldSend().To(gen.Atom("audit")).None().Assert() // neversub.ShouldSend().To(gen.Atom("worker")).Priority(gen.MessagePriorityHigh).Once().Assert()
sub.ShouldSpawn().Factory(factoryWorker).Times(3).Assert()
sub.ShouldLog().Level(gen.LogLevelError).Containing("timeout").Once().Assert()sub.ShouldCall().To(gen.Atom("db")).ErrorIs(gen.ErrTimeout).Once().Assert()sub.ShouldSend().Where(func(r check.Send) bool {
n, ok := r.Message.(Notification)
return ok && n.Urgent
}).AtLeast(1).Assert()sub.ShouldTerminate().Abnormally().Once().Assert() // crashed, panicked, killed, or errored
sub.ShouldTerminate().None().Assert() // still runningnode.ShouldReceiveDown().To(watcher).About(worker).Reason(gen.TerminateReasonKill).Once().Within(time.Second).Assert()node.ShouldDeliver().To(worker).Within(time.Second).Once().Assert()sub.SendMessage(client, "first")
mark := sub.Mark()
sub.SendMessage(client, "second")
sub.ShouldSend().To(client).Since(mark).Once().Assert() // only the reply to "second"spawn, ok := sub.ShouldSpawn().Once().Capture()
childPID := spawn.Childsends := sub.ShouldSend().To(gen.Atom("worker")).Collect() // []check.Send, in ordercheck.NoError(t, err)
check.ErrorIs(t, err, gen.ErrProcessUnknown)
check.Equal(t, JobQueued{ID: "42"}, got)
check.True(t, behavior.started)sub.OnCall(gen.Atom("db")).Where(check.IsType[Query]()).Respond(rows)HandleInspect as the observability surface of an actor, and what it makes possible
An actor's state is private by design. Nothing outside it can read a field, and that is what makes the actor model safe: no locks, no shared memory, no races. It is also what makes a running actor system opaque. A debugger can pause one goroutine, but a node is thousands of them, and the interesting question is rarely about one process in isolation.
HandleInspect is the one sanctioned way out of that. It is not an API for other processes to use, not a message handler, and not a place to compute anything. It is the actor's answer to a single question, asked from the outside at an arbitrary moment: what do you currently believe?
func (w *Worker) HandleInspect(from gen.PID, item ...string) map[string]string {
return map[string]string{
"state": w.state,
"queue_depth": fmt.Sprintf("%d", len(w.queue)),
"last_error": w.lastError,
}
}The mechanics are covered in Actor: requests arrive on the Urgent queue, values are strings, and the callback must return immediately. This page is about the part the mechanics do not tell you - what to put in there, and what it buys.
The division of labour is worth stating plainly, because it is unusual. The framework does not define what belongs in that map. It has no schema for it, no field registry, no notion of which keys are meaningful. All it does is instrument: deliver the request, call your callback, and carry the result to whoever asked - the observer renders it, its MCP surface returns it as a tool result, a sibling process gets it from process.Inspect. The content is entirely yours, and so is the diagnostic value. An actor whose callback returns three convenient fields is an actor that cannot be diagnosed, and no amount of tooling above it changes that.
This is the whole of it. Diagnosis is limited to the fields you chose to expose, and that choice is made months before the incident by someone who does not know what the incident will be.
The failure mode is specific and easy to walk into. An actor exposes the values that were easy to format - a name, a counter, a boolean - and omits the ones that carry the decision it just made. Everything looks reasonable in the inspection output, and the actual state is unreachable.
A concrete case. A leader-election actor exposed four fields: cluster id, term, a boolean "am I the leader", and a peer count. Every one of them is true and none of them is enough. The actor has three roles, not two - follower, candidate, leader - and a boolean cannot say which of the first two it is. So a replica stuck as a candidate for hours, campaigning and never winning, was indistinguishable from a healthy follower on every surface the system offered. The peer count said 3 without saying which three, so a stale entry pointing at a process that no longer existed looked identical to a live peer. Nothing reported whether an election timer was still armed, which is the difference between a replica that will try again and one that has stopped trying.
The state was recoverable, but only sideways: by comparing per-connection message counters between nodes to prove that a leader had emitted nothing for a day, and by taking four inspection readings twenty minutes apart to prove a term had stopped advancing. Each of those would have been one field.
The rule that follows is not "expose more". It is: expose the state your code branches on. If a line of your actor reads a field to decide what to do, an operator will eventually need to read the same field to understand what was done.
In practice, five kinds of field carry almost all the diagnostic value.
The derived role, not the raw flags. If your actor has three states and you store two booleans, expose the resolved state as a word. Whoever reads it should not have to reconstruct your state machine from its parts.
Identities, not counts. peers: 3 cannot be checked against reality. peers: n1@host,n2@host,n3@host can, and it is how a stale or duplicated entry becomes visible.
Things you dropped silently. Every place where your code decides to ignore a message is invisible by construction. A counter per reason turns a silent drop into evidence: dropped: cluster_id_mismatch=2,stale_heartbeat=17. This is usually the highest-value field in the map, because a message that was discarded leaves no other trace anywhere.
Whether your timers are armed. For any actor driven by SendAfter, "waiting to act" and "has stopped acting" look the same from outside. One boolean separates them. Note that testing the gen.CancelFunc you stored does not answer it: SendAfter fires once, and nothing clears your variable when it does, so a spent handle still looks armed. Track the arming explicitly or the field will lie.
When the last transition happened. A value plus the timestamp it last changed answers "is this stuck?" in one reading. Without the timestamp it takes two readings and a guess about how long to wait between them.
Keep it cheap. The callback runs on the actor's own goroutine, so while it runs the mailbox is not being drained. Format what you already hold; do not compute, do not call out, do not touch the network.
act.Actor, act.Pool, act.Router, act.Supervisor and the extra-library actors all have state of their own worth reporting - pool statistics, restart counts, election state. If your implementation overrides HandleInspect, that state must not disappear.
The base behaviors handle this for you: they compute their own fields first and merge yours on top.
So a consumer adds fields, and may deliberately replace one, but cannot erase the rest. If you are implementing HandleInspect on top of one, you can rely on it: the framework's fields will be there beside yours.
The base behaviors namespace their own keys with a reserved ergo: prefix - ergo:pool_size, ergo:children_total, ergo:state and so on. That is what makes the merge safe in both directions: a field of yours cannot collide with one of theirs by accident, and you can still override one deliberately by naming it with the prefix. If you write a base behavior of your own, follow the same shape and the same prefix; if you are the consumer, keep your keys unprefixed and they will never clash.
Everything above assumes the answer fits in a map. Plenty of actors hold state that does not: a registry with a hundred thousand sessions, a scheduler with a deep queue, a cache with a million keys. Returning all of it is not an option - the callback must be cheap, and nobody can read it anyway.
The item arguments are the way out, and they are more than a field filter. Treat them as a small query vocabulary with three tiers.
A bounded summary by default. With no items, answer with aggregates only: totals, distribution, the extremes. The size of this answer must not depend on the size of the state.
A help item that names what can be asked. This is what makes the surface self-describing. A reader - human or agent - does not need to know your schema in advance; it asks once and learns the vocabulary. Nothing in the framework enforces this or knows about it: it is a convention you implement, which is precisely why it is worth implementing - without it the vocabulary exists only in your source.
Parameterised items that drill into one entity. session <id>, user <id>, top slowest - each returns detail about a small, named part of the state.
Four things that matter in that shape.
Cap every answer. A query that can match a million entries must return the first n and say so. An unbounded answer reintroduces the problem the queries exist to avoid.
Never scan. user <id> above reads an index the actor already maintains. If answering a query means walking the whole state, either keep the index or do not offer the query - the callback holds the actor's own goroutine while it runs.
Report an unknown item. Returning nothing for a key the caller asked about is indistinguishable from a value that happens to be empty. <unknown item> costs one line and removes the ambiguity.
Keep it read-only. A query language over item is a good idea; a command language over it is not. Callers treat inspection as free - tools cache and retry it, an agent exploring a symptom calls it dozens of times - so anything that changes state belongs in HandleCall instead.
Answering queries also changes what the vocabulary itself tells a reader. session <id>, user <id>, top slowest says that this actor is indexed by session and by user and tracks latency, before anyone looks at a single value. That is diagnostic information in its own right: two actors with the same number of fields can differ enormously in how much they will let you ask, and help is where the difference becomes visible.
A single inspection call is a snapshot of one actor. What makes the callback worth designing carefully is that everything above it is built from those snapshots.
One node, read by a human. shows a process list, its tree, and the inspection output of whichever process you open, updating live. This is the view for "I know roughly where the problem is".
A whole cluster, read by an AI. The exposes the same inspection as resources and tools an agent asks for on demand: enumerate processes across the cluster, inspect any of them, follow the topology, capture a profile. Every node runs the built-in system application that answers those questions, and the one node serving MCP reaches all of them, so a single conversation covers the cluster.
The difference between the two is not convenience, it is method. A dashboard answers questions decided in advance. An agent holding the whole inspection surface can work the other way round: start from a symptom, enumerate what exists, read the state of the processes that look implicated, correlate across nodes, and narrow down. Point diagnosis becomes system diagnosis, because nothing has to be selected up front.
And it reads your source. This is the part that changes the character of the work. An agent that can inspect live actor state and read the code that produced it is looking at cause and effect at once. The state says what the system believes; the source says which branch produced that belief and what it will do next. Neither alone is enough - a field value without the code is a number, and code without runtime state is a hypothesis - and together they close the loop that a debugger closes for a single-threaded program, but across a live distributed system that cannot be paused.
That is the reason to treat HandleInspect as a design surface rather than a debug convenience. The value of every layer above it - the observer view, the cluster-wide diagnostic, the agent that explains what it found - is bounded by whether the field it needed was exposed.
Follow the query vocabulary to its conclusion and a summary plus a handful of drill-downs stops being a debug aid. Look up one entity by id, list what belongs to one tenant, show the worst ten by latency - that is the read half of an admin panel, over live state, for the cost of a switch statement. No separate service, no query layer, no HTTP handlers, no second deployment, and no copy of the data: the actor already holds the state, and it already maintains the indexes because it needs them to do its job. The observer and MCP supply the front end.
Worth being clear about the boundaries, so nobody builds the wrong thing on it.
It is the read half only. Commands go through HandleCall, for the reasons above.
It is for operators, not end users. There is no authentication on it beyond access to the node, no per-tenant scoping, and no audit trail. Whoever can reach the observer or the MCP entry point can ask anything the actor answers. That is the right trade for an engineering tool and the wrong one for a customer-facing feature.
It is a diagnostic surface, not a data API. Values are strings, keys are yours to rename, and nothing versions them. If another system needs this data, give it a proper request and response instead of parsing an inspection map.
Before shipping an actor, read your own HandleInspect against these:
Does it report the role or phase as a word, rather than the flags it is derived from?
Does it name the peers, children or targets it holds, rather than counting them?
Does every branch that silently drops or ignores something have a counter here?
If the actor is timer-driven, can a reader tell "waiting" from "stopped"?
- the callback's mechanics and constraints
- the human-facing view of the same data
- the same data as an agent-facing MCP surface
- using Ergo as both runtime and diagnostic surface
Starting applications on remote nodes
Remote application starting means launching an application on another node from your code. The remote node has the application loaded but not running. You send a start request, and the application starts on that node with the mode and options you specify. The application runs under the remote node's supervision, part of the remote node's application tree.
This capability enables dynamic application deployment and orchestration. You have a cluster of nodes, each with applications loaded but waiting. A coordinator node decides which applications should run where, based on load, topology, or scheduling logic. Remote application starting makes this coordination explicit and controllable.
Like remote spawning, remote application starting isn't automatic. Security matters. You don't want arbitrary nodes starting arbitrary applications. The framework requires explicit permission - the remote node must enable each application individually and can restrict which nodes are allowed to start it.
The gate is the application registry, not the flag. EnableRemoteApplicationStart is on in gen.DefaultNetworkFlags, and a node that configures no flags of its own is given those defaults, so on an ordinary node the flag is already true. What actually stops a peer is that no application can be started remotely until network.EnableApplicationStart(name) has allowed it.
Set the flag when you want the transport-level door shut - a node that must never accept a remote application start, whatever it has enabled. Note that it is only consulted when Flags.Enable is true, and that supplying Flags yourself replaces the whole default set rather than adjusting one member of it:
This flag is a global switch. With it disabled, all remote application start requests fail immediately with gen.ErrNotAllowed. With it enabled, requests proceed to per-application permission.
Even with EnableRemoteApplicationStart turned on, remote nodes can't start anything until you explicitly enable specific applications:
Now remote nodes can request starting the "workers" application. The application must be loaded on this node (via node.ApplicationLoad). If it's not loaded, remote start requests fail with gen.ErrApplicationUnknown. If it's already running, remote start requests fail because you can't start a running application again.
The application name is the permission token. Remote nodes must use this exact name when requesting starts. If they request "workers" and you haven't enabled it, the request fails. If they request "admin_app" without permission, it fails. You control what's startable remotely.
By default, EnableApplicationStart allows all nodes to start the application. But you can restrict it to specific nodes:
Now only those two nodes can start the workers application. Requests from other nodes fail with gen.ErrNotAllowed.
You can update the access list dynamically:
Calling EnableApplicationStart again with the same application name updates the access list.
To remove nodes from the access list:
This removes scheduler@node2 from the allowed list. Other nodes in the list remain allowed.
To completely disable remote starting for an application:
Without any node arguments, DisableApplicationStart removes the permission entirely. All future start requests for that application fail.
To re-enable with an open access list (any node can start):
This is the explicit "allow all nodes" configuration.
To start an application on a remote node, first get a gen.RemoteNode interface:
With the remote node handle, start an application:
The application starts on the remote node. The start is synchronous - the call blocks until the remote node confirms the application started or returns an error.
The mode is the application's termination policy: it decides when the termination of a group member takes the whole application down. For remote starts you can state it explicitly, overriding the mode the spec declared:
If you use ApplicationStart without specifying a mode, the application starts with the mode it was loaded with (set during ApplicationLoad).
Nothing restarts the application itself. The mode decides when it stops, never whether it comes back, and that holds for a remote start exactly as it does for a local one. Choose it by how much of the group must be alive for the service to mean anything: a permanent application is one where a missing member makes the rest pointless.
For details on application modes, see .
When an application starts remotely, parent tracking is set at multiple levels:
Application Parent: Set to the requesting node name:
Process Parent for Group Members: Processes started directly by the application (listed in Group) receive the requesting node's core PID as their parent:
Process Parent for Descendants: If those processes spawn children, the children receive their spawning process PID as parent (normal process hierarchy):
Only the first-level processes (application group members) have the cross-node parent relationship. Subsequent generations follow standard process parent-child relationships within the local node.
This parent information is for tracking and auditing, not supervision. The application is supervised by the local application supervisor on the remote node. Terminating the requesting node does not affect the running application.
By default, remote applications don't inherit environment variables from the requesting node. To enable environment inheritance:
Now when you start an application remotely, the application's processes receive a copy of the requesting node's core environment. This enables configuration propagation - your scheduler node has configuration in its environment, and applications started remotely inherit it.
Important: Environment variable values must be EDF-serializable. Strings, numbers, booleans work fine. Custom types require registration via node.Network().RegisterType (see for details on the type registry; the legacy edf.RegisterTypeOf still works but is deprecated). If an environment variable contains a non-serializable value (e.g., a channel, function, or unregistered struct), the remote application start fails entirely with an error like "no encoder for type <type>". The framework doesn't skip problematic variables: any non-serializable value causes the entire start request to fail.
When you call remote.ApplicationStart:
Check capabilities - The local node checks if the remote node's EnableRemoteApplicationStart flag is true (learned during handshake). If false, fail immediately.
Create start message - Package the application name, startup mode, and options into a MessageApplicationStart protocol message. Include a reference for tracking the response.
Send request - Encode and send the message to the remote node. Wait for a response (this is synchronous - remote application start blocks until the remote node replies).
If anything fails (application not found, access denied, already running, remote node terminating), the error is returned to the caller. The entire operation is synchronous - you call ApplicationStart and block until the application is running or an error occurs.
Idempotency - Starting an already-running application returns an error. If you're unsure of the application's state, query it first using remote.ApplicationInfo to check if it's already running. Or handle the error gracefully and treat "already running" as success.
Startup time - Some applications take time to start - they might load configuration, establish connections, initialize state. The remote start call blocks during this entire startup sequence. If startup is slow, the caller waits. For long-running startup logic, consider using async patterns or monitoring application state separately.
Failure modes - Remote application start can fail in ways local start can't. The network connection can drop mid-request. The remote node can crash before responding. The application might fail to start for reasons specific to that node (missing dependencies, configuration issues). Handle errors explicitly.
Resource contention - An application starting on a remote node consumes that node's resources (CPU, memory, file descriptors). If multiple nodes simultaneously request starting applications on the same remote node, it could become resource-constrained. Coordinate start requests to avoid overwhelming nodes.
Application lifecycle - Once started remotely, the application runs until explicitly stopped or until the remote node terminates. The requesting node has no automatic control over the running application. gen.RemoteNode has no stop counterpart: to stop it later, either ask the node's system application through manage.RequestDoAppStop, or coordinate it with your own messages.
Supervision independence - The application is supervised by the remote node, not by the requesting node. If the requesting node crashes, the application keeps running. If the remote node crashes, the application terminates. This independence is important for operational reasoning - the application's lifecycle is tied to where it runs, not to who started it.
Configuration management - Applications often need configuration. With ExposeEnvRemoteApplicationStart, you can propagate environment variables. But this creates coupling - the application depends on the requesting node's configuration. Consider whether configuration should come from the remote node's local environment, from a centralized configuration service, or from the requesting node. The right answer depends on your architecture.
Dynamic orchestration - A coordinator node decides which applications should run on which nodes based on cluster state, resource availability, or scheduling logic. The coordinator starts applications dynamically as needed.
Staged deployment - Applications are pre-loaded on nodes but not started. A deployment controller starts them in a specific order, waiting for health checks between stages. This enables controlled rollouts.
Capacity management - Some applications run only during high-load periods. A resource manager monitors load and starts applications on additional nodes when needed, then stops them when load decreases.
Geographic distribution - Applications are loaded across multiple regions. A traffic manager starts applications in specific regions based on user distribution, latency requirements, or failover needs.
Testing and validation - Test frameworks load applications on test nodes but don't start them until test execution. Tests start applications with specific configurations, run scenarios, then stop them. This enables repeatable, isolated testing.
Maintenance windows - During maintenance, you stop applications on a node, perform updates, then start them again. Remote start enables coordinated maintenance across a cluster without manually SSHing to each node.
Remote application starting is about control and coordination. If your cluster has static application deployment (applications always run on specific nodes), you don't need this feature - use supervision trees and let supervisors start applications automatically. If your cluster has dynamic application deployment (applications move between nodes based on conditions), remote application starting enables that flexibility.
For understanding the underlying network mechanics, see . For controlling connections to remote nodes, see . For understanding application lifecycle and modes, see .
Process control and fault tolerance
Building reliable systems means accepting an uncomfortable truth: failures will happen. Hardware fails. Networks partition. Bugs exist in code. The question isn't whether your processes will crash, but what happens when they do.
The supervision tree model provides an answer. Instead of trying to prevent all failures, you structure your system so failures are expected, isolated, and automatically recovered from.
The model divides processes into two distinct roles:
Workers do the actual work. They handle requests, process data, manage state, and inevitably, sometimes crash when things go wrong.
Supervisors watch over workers. Their only job is to start child processes and restart them when they fail. Supervisors don't do application work - they manage lifecycle.
This separation is crucial. If workers handled their own restart logic, a bug in that logic would prevent recovery. By moving restart responsibility to a separate supervisor, you ensure that failures in workers can always be recovered.
A supervisor starts its children and monitors them. When a child crashes, the supervisor decides what to do based on its restart strategy. Should it restart just this one child? Restart all children? Restart all children in a specific order?
The strategy depends on the relationships between children. If they're independent, restart just the failed one. If they depend on each other, restart all of them to ensure consistent state. If they have startup dependencies, restart in order.
Supervisors can supervise other supervisors, forming a tree. At the top might be an application supervisor. Below it, supervisors for different subsystems. Below those, the actual workers. When a worker crashes, only its portion of the tree is affected. The rest of the system continues running.
This tree structure creates fault isolation boundaries. A crashed database worker doesn't affect the HTTP handler workers. A failed cache process doesn't take down the authentication processes. Each supervision subtree handles its own failures without cascading them upward.
The Erlang community calls this "let it crash." It sounds reckless, but it's actually disciplined. Instead of defensive programming trying to handle every possible error, you let processes fail and rely on supervisors to restart them in a clean state. Often, a fresh restart clears transient problems that would be difficult to handle explicitly.
Ergo Framework implements supervision through the act.Supervisor actor. When you create a supervisor, you specify its children and restart strategy. The framework handles the monitoring and restart logic.
Workers are typically act.Actor implementations - regular actors that do application work. Supervisors are act.Supervisor implementations - actors whose behavior is managing children.
Because supervisors are also actors, they can be supervised. This is how you build the tree: supervisors supervising supervisors supervising workers, all the way down.
The tree structure emerges from how you compose supervisors and workers. There's no special tree-building API. You just nest supervisors, and the tree forms naturally.
The supervision tree model leads to systems with interesting properties.
Self-healing - Failures trigger automatic recovery. Most transient problems resolve themselves through restart.
Graceful degradation - When a subsystem fails, only that part stops working. The rest continues serving requests.
Operational simplicity - Instead of complex error handling throughout your code, you centralize recovery logic in supervisors.
The trade-off is that you need to design processes that can restart cleanly. State that must survive restarts needs to be externalized - in databases, in other processes, or rebuilt from messages. But this discipline leads to more robust designs anyway.
Understanding supervision requires seeing it in practice. The chapter covers the specifics: restart strategies, child specifications, and practical patterns for structuring your application.
The combination of the actor model (isolated processes, message passing) and supervision trees (automatic recovery) gives you the tools to build systems that handle failures gracefully. It's a different approach than traditional error handling, but one that scales well to distributed systems where failures are inevitable.
WebSocket provides persistent bidirectional connections between clients and servers. Unlike HTTP request-response, a WebSocket connection remains open for extended periods, allowing both client and server to send messages at any time.
The framework provides WebSocket meta-process implementation that integrates WebSocket connections with the actor model. Each connection becomes an independent actor addressable from anywhere in the cluster.
WebSocket connections need two capabilities simultaneously:
Continuous reading: Connection must block reading messages from the client. When a message arrives, forward it to application actors for processing.
Asynchronous writing: Backend actors must be able to push messages to the client at any time - notifications, updates, events from the actor system.
This is exactly what meta-processes solve. External Reader continuously reads from the WebSocket. Actor Handler receives messages from backend actors and writes to the WebSocket. Both operate concurrently on the same connection.
Two meta-processes work together:
WebSocket Handler: Implements
A single actor processes messages sequentially. This is fundamental to the actor model - it eliminates race conditions and makes reasoning about state straightforward. But it also means one actor can become a bottleneck. If messages arrive faster than the actor can process them, the mailbox grows, latency increases, and eventually the system stalls.
The standard solution is to run multiple workers. Instead of sending requests to one actor, distribute them across several identical actors processing in parallel. This works, but now you need routing logic: pick a worker, check if it's alive, handle mailbox overflow, restart dead workers. This boilerplate appears in every pool implementation.
act.Pool solves this. It's an actor that manages a pool of worker actors and automatically distributes incoming messages and requests across them. You send to the pool's PID, the pool forwards to an available worker. The pool handles worker lifecycle, automatic restarts, and load balancing. From the sender's perspective, it's just one actor. Under the hood, it's N workers processing in parallel.
Like act.Actor provides callbacks for regular actors, act.Pool uses the act.PoolBehavior
Publish/Subscribe Event Mechanism
The actor model excels at point-to-point communication. Process A sends a message to process B. Process C makes a request to process D. Each interaction has a specific sender and receiver.
But some scenarios need one-to-many communication. A price feed updates and dozens of trading strategies need the new price. A user logs in and multiple subsystems need notification. A sensor reading arrives and various monitoring processes need to react. You could send individual messages to each interested process, but then the producer needs to track all consumers. When consumers come and go, the producer's consumer list becomes a maintenance burden.
Events solve this with publish/subscribe semantics. A producer registers an event and publishes values to it. Consumers subscribe to the event without the producer knowing who they are. The framework handles message distribution - when the producer publishes an event, all current subscribers receive it. Subscribers can come and go dynamically, and the producer's code doesn't change.
A process becomes an event producer by calling RegisterEvent with an event name and options. The call returns a token - a unique reference that proves ownership. Only the process holding this token (or a process it delegates to) can publish events under this name.
The Notify option controls whether the producer receives notifications about subscriber changes. When enabled, the producer receives
gen.MetaProcess
NewLog / NewLogT
gen.Log
NewCron / NewCronT
gen.Cron
NewNetwork / NewNetworkT
gen.Network
NewRemoteNode / NewRemoteNodeT
gen.RemoteNode
NewRegistrar / NewRegistrarT
gen.Registrar
NewResolver / NewResolverT
gen.Resolver
NewConnection / NewConnectionT
gen.Connection
NewCore / NewCoreT
gen.Core
NewCoreTargetManager / NewCoreTargetManagerT
the core's target manager
Does each value that can get stuck carry the time it last changed?
If the state is large, is the default answer bounded, is there a help item, and is every query backed by an index rather than a scan?
Does it return immediately, with no computation, no I/O, and no locks?
If it embeds a base behavior, does the base state still come through?
Debugging - the wider set of techniques this fits into
Remote processing - The remote node receives the message, checks if the application is enabled for remote start, checks if the requesting node is allowed, verifies the application exists and isn't already running, calls the application's start logic with the given mode.
Response - The remote node sends back a MessageResult containing either success or an error. The local node receives this, resolves the waiting request, and returns the result to the caller.
http.HandlerWebSocket Connection: Meta-process managing one WebSocket connection. External Reader continuously reads messages from client, sends them to application actors. Actor Handler receives messages from actors, writes them to client. Connection lives until client disconnects or error occurs.
Use websocket.CreateHandler to create handler meta-process:
Handler options:
ProcessPool: List of process names that will receive messages from WebSocket connections. When connection is established, handler round-robins across this pool to select which process receives messages from this connection. If empty, connection sends to parent process.
HandshakeTimeout: Maximum time for WebSocket upgrade handshake. Default 15 seconds.
EnableCompression: Enable per-message compression. Reduces bandwidth for text messages.
CheckOrigin: Function to verify request origin. Return true to accept, false to reject. Default rejects cross-origin requests. Use func(r *http.Request) bool { return true } to accept all origins.
When client connects:
HTTP request arrives, handler upgrades to WebSocket
Handler spawns Connection meta-process
Connection sends MessageConnect to application
External Reader enters continuous read loop
Actor Handler waits for backend messages
During connection lifetime:
Client messages: External Reader reads → sends to application
Server messages: Application sends → Actor Handler writes to client
Both directions operate simultaneously
When client disconnects:
ReadMessage() returns error
External Reader sends MessageDisconnect to application
Connection closes socket
Meta-process terminates
Three message types flow between connections and actors:
websocket.MessageConnect: Sent when connection established.
Receive this to track new connections:
websocket.MessageDisconnect: Sent when connection closes.
Receive this to clean up connection state:
websocket.Message: Client message received or server message to send.
Receive messages from client:
Send messages to client:
When sending, Type defaults to MessageTypeText if not set. ID field is ignored - target is specified in SendAlias() call.
Connection meta-processes have gen.Alias identifiers that work across the cluster. Any actor on any node can send messages to any connection:
Network transparency makes every WebSocket connection addressable like any other actor. Backend logic scattered across cluster nodes can push updates to specific clients without intermediaries.
Create client-side WebSocket connections with websocket.CreateConnection:
CreateConnection performs WebSocket dial during creation. If dial fails, error is returned. If successful, connection is established but meta-process is not started yet. Call SpawnMeta() to start the meta-process. If spawn fails, call conn.Terminate(err) to close the connection.
Connection options:
URL: WebSocket server address. Use ws:// or wss:// scheme.
Process: Process name that will receive messages from server. If empty, sends to parent process.
HandshakeTimeout: Maximum time for connection handshake. Default 15 seconds.
EnableCompression: Enable compression. Must match server setting.
Client connections work identically to server connections. External Reader reads from server, Actor Handler sends to server. Messages use the same websocket.Message type.
Handler accepts ProcessPool - list of process names to receive connection messages. Handler distributes connections across this pool using round-robin:
Connection 1 sends to "handler1", connection 2 to "handler2", connection 3 to "handler3", connection 4 to "handler1", etc. This distributes load across multiple handler processes.
Useful for scaling: spawn multiple handler processes, each managing subset of connections. Prevents single handler from becoming bottleneck.
The key difference from ActorBehavior: Init returns PoolOptions that define the pool configuration. All callbacks are optional except Init.
Embed act.Pool in your struct and implement Init to configure workers:
PoolSize below 1 is not an error and not "no workers": ProcessInit substitutes the default of 3, and the substituted value is what ergo:pool_size then reports. WorkerFactory is the field with no default: leave it nil and the first worker spawn fails, ProcessInit returns that error, and the pool does not start.
The pool spawns workers during initialization with LinkParent: true. That link runs one way, from worker to pool: if the pool terminates, its workers get an exit signal and go with it. The reverse is not true - a crashing worker sends the pool nothing.
The pool notices a dead worker lazily instead. When it forwards a message and the target answers gen.ErrProcessUnknown or gen.ErrProcessTerminated, it spawns a replacement and forwards the message there. So a worker that dies while the pool is idle is replaced on the next message addressed to it, not at the moment of death.
Workers are created using the WorkerFactory. This is the same factory pattern as regular Spawn - it returns a gen.ProcessBehavior instance. The workers can be act.Actor, act.Pool (nested pools), or custom behaviors.
The combination of PoolSize and WorkerMailboxSize bounds how much work the pool holds: PoolSize messages being handled, plus PoolSize × WorkerMailboxSize waiting in the workers' mailboxes. A message being handled has already left its mailbox, so the two add up. There is no buffer at the pool itself. Once every mailbox is full, further messages are dropped rather than rejected - the sender is not told, as the next section explains:
That product is the work in flight the pool can hold. Past it the message is dropped: the pool logs an error, increments ergo:messages_unhandled and releases the message. The sender is not told. ErrProcessMailboxFull comes from a target's own queue on the ordinary send path, and the pool's forwarding is not that path - a Send to a saturated pool returns nil, and a Call ends in the caller's own timeout with no indication of the cause.
So this is a limit, not backpressure. If an external API is to answer "503 Service Unavailable" when the pool is saturated, that decision has to be made before the pool: check ergo:messages_unhandled from the inspect callback, or gate admission in the handler. The pool size controls maximum concurrency and the mailbox size controls burst capacity - tune both against worker processing speed and acceptable latency, and treat a rising drop counter as the signal that the sizing is wrong.
When you send a message or make a call to the pool, act.Pool automatically forwards it to an available worker:
Forwarding happens for messages in the Main queue (normal priority). The pool maintains a FIFO queue of worker PIDs. When a message arrives:
Pop a worker from the queue
Forward the message using Forward (preserves original sender and ref)
Check result:
Success → push worker back to queue
ErrProcessUnknown / ErrProcessTerminated → spawn replacement, forward to it
ErrProcessMailboxFull → push worker back, try next worker
Repeat until successful or all workers tried
If all workers have full mailboxes, the message is dropped and logged. The pool doesn't have its own buffer beyond the workers' mailboxes. This is intentional - backpressure should propagate to senders.
The pool forwards Regular messages, Requests, and Events. Exit signals and Inspect requests are handled by the pool itself (they're not forwarded to workers).
Workers receive the original sender's PID, not the pool's PID. When a worker processes a forwarded message, from points to whoever sent to the pool:
The same applies to Call requests. Workers see the original caller's from and ref. When they return a result or call SendResponse, it goes directly to the original caller, bypassing the pool entirely.
This is why forwarding is transparent. The worker doesn't know it's part of a pool. It processes messages as if they were sent directly to it.
Automatic forwarding applies only to the Main queue (normal priority). Urgent and System queues are handled by the pool itself through HandleMessage and HandleCall callbacks:
The same for synchronous requests:
act.Pool keeps its counters in unexported fields, so an embedding type cannot read p.pool.Len() or p.forwarded from its own package - that does not compile. The published route is the inspect callback, whose keys are listed below.
Important: High-priority requests that return (nil, nil) from HandleCall are not forwarded to workers. They're simply ignored, and the caller times out. Forwarding only happens for Main queue messages. If you want a request to be handled, either:
Send it with normal priority (goes to workers)
Handle it explicitly in pool's HandleCall and return a result
Use high priority only for pool management that should be handled by the pool itself, not for work that should go to workers.
Adjust the pool size at runtime with AddWorkers and RemoveWorkers:
AddWorkers spawns new workers with the same factory and options used during initialization. They're added to the FIFO queue and immediately available for work.
RemoveWorkers takes workers from the queue and sends them gen.TerminateReasonNormal via SendExit. That exit goes to the Urgent queue, and every run loop drains Urgent before System before Main, so a removed worker stops at its next dispatch: the message it is handling now finishes, and everything already queued behind it in Main does not. SetTrapExit does not soften this either - the trap only applies to an exit from the worker's parent, and here the parent is the pool that sent it.
If in-flight work must not be lost, drain before removing: stop feeding the pool, wait for the workers' mailboxes to empty, then call RemoveWorkers.
Both methods return the new pool size after the operation. They fail if called from outside Running state.
Workers are spawned with LinkParent: true, which links them to the pool and not the pool to them - a crashing worker sends the pool no signal at all. Detection happens in the forward path instead: the pool pops a worker, forwards, and if the answer is ErrProcessUnknown or ErrProcessTerminated it spawns a replacement with the same factory and arguments and forwards the message to the new worker.
This is automatic restart, not supervision. The pool doesn't track worker history or apply restart strategies, and it does not learn of a death until it next tries to use that worker - a pool sitting idle keeps a dead PID in its queue until the next message. If you need sophisticated restart strategies, use a Supervisor to manage the pool and its workers.
Pools expose internal metrics via Inspect:
All of these keys use the reserved ergo: prefix. A HandleInspect you implement is merged on top of them, so your fields are added beside these rather than replacing the set - and one of these is overridden only if you name it with the prefix.
Use this for monitoring pool health. High ergo:messages_unhandled indicates workers are overwhelmed. High ergo:worker_restarts suggests worker stability issues.
ProcessOptions.Fallback does not help here, though it is the natural thing to reach for. Two reasons, either of which is enough. PoolOptions carries only PoolSize, WorkerMailboxSize, WorkerFactory and WorkerArgs - the pool builds its workers' ProcessOptions itself and sets no fallback, and there is no runtime setter for one. And the pool delivers with Forward, which pushes onto the worker's queue directly and answers gen.ErrProcessMailboxFull; the fallback is consulted only on the ordinary routing path that Send takes. A message the pool cannot place is dropped and counted, never diverted. The remedies are AddWorkers, a larger WorkerMailboxSize, or shedding load before the pool.
Use a pool when:
One actor is a bottleneck (mailbox growing, latency increasing)
Work items are independent (no ordering dependencies)
Workers are stateless or can reconstruct state cheaply
Don't use a pool when:
Work items depend on previous items (pools don't guarantee ordering)
Workers maintain critical state that can't be lost on restart
Concurrency isn't the bottleneck (single actor is fast enough)
Pools are for horizontal scaling of stateless work. If workers need state coordination, message-type dispatching, or key affinity, use Router instead - it owns named slots and lets user code decide where each message goes.
Set WorkerMailboxSize to limit backpressure propagation. Unbounded mailboxes let workers accumulate huge queues, hiding the overload until memory exhausts. Bounded mailboxes cause forwarding to try next worker, eventually reaching the sender with backpressure.
Don't forward Exit signals intentionally. The pool doesn't forward Exit messages to workers. If you need to broadcast shutdown to all workers, iterate manually and send to each worker PID.
Monitor forwarding metrics. If ergo:messages_unhandled increases, your pool is undersized or workers are too slow. Scale up with AddWorkers or optimize worker processing.
Use priority for pool management. Send management commands with MessagePriorityHigh to ensure they go to the pool, not forwarded to workers.
Nested pools are possible but rarely useful. A pool of pools adds latency without much benefit. Prefer one pool with more workers over nested layers.
gen.MessageEventStartgen.MessageEventStopThe Buffer option specifies how many recent events to keep. When a new subscriber joins, it receives the buffered events as a catch-up mechanism. Set this to zero if events are only relevant at the moment they're published. Set it to a reasonable number if new subscribers should see recent history.
Events are identified by name and node. The combination must be unique. Two processes on the same node can't register events with the same name. But processes on different nodes can register events with the same name - they're different events.
Introduced in v3.3.0.
By default, only the token holder can publish to an event. This prevents unauthorized processes from publishing events they don't own. For events that represent an internal node-wide bus, this protection is sometimes more friction than benefit. You end up distributing the token across multiple processes, or through environment variables, just so known participants can publish.
The Open option disables the token check on publish.
Any local process can now publish to this event by name, regardless of the token value. The owner check on UnregisterEvent is unaffected. Only the registering process (or the node, for node-level events) can unregister.
Consider a bus inside the node where application events land: "order created", "user signed up", "payment received". They come from different modules, and subscribers (notifier, analytics, search indexer) pick up whichever ones matter. Nobody owns the bus. Handing a shared token to every emitter is plumbing that protects nothing.
Open events trade the typo and bug protection that the token provides for simpler distribution. A process can accidentally publish to an event it was never supposed to touch. Use this option when the event is deliberately a shared bus and the token ceremony adds no real security in your context.
Publishing an event sends it to all current subscribers.
You pass your application data directly. The framework wraps it in gen.MessageEvent automatically, adding the event identifier and timestamp. Subscribers receive the complete gen.MessageEvent structure containing your data.
The producer uses the token obtained during registration. If you try to publish with an incorrect token, the operation fails. This prevents unauthorized processes from publishing events they don't own.
Event publishing is fire-and-forget. The producer doesn't wait for acknowledgment or know how many subscribers received the event. The framework handles distribution asynchronously.
Processes subscribe to events through links or monitors, the same mechanisms used for process lifecycle tracking.
LinkEvent creates a link to an event. You receive event messages as they're published. If the event producer terminates or unregisters the event, you receive an exit signal. The link semantics apply - by default, you'd terminate too.
MonitorEvent creates a monitor on an event. You receive event messages and a down notification if the producer terminates or the event is unregistered, but you don't terminate automatically.
Both methods return buffered events upon successful subscription:
The buffered events let subscribers catch up on what happened before they joined. If the buffer size was 10 and 5 events have been published, new subscribers receive those 5 events immediately.
For local events, you can omit the node name: gen.Event{Name: "price_update"}. The framework fills in the local node name. For remote events, specify the full event identifier including the remote node name.
Events exist from registration until unregistration or producer termination.
When you register an event, it becomes available for subscription. Processes on any node can subscribe if they know the event name and node. The framework tracks all subscribers and distributes published events to them.
When the producer terminates, the event is automatically unregistered. All subscribers receive termination notifications (exit signals for links, down messages for monitors). The event name becomes available for registration again.
The producer can explicitly unregister an event with UnregisterEvent. This triggers the same notifications to subscribers. Use this when you're done publishing events but your process continues running.
If a subscriber terminates or unsubscribes (via UnlinkEvent or DemonitorEvent), the producer doesn't receive notification unless Notify was enabled. With Notify, the producer receives gen.MessageEventStop when the last subscriber leaves.
All examples so far registered events from a process. That process is the producer, and the event exists only as long as the process runs. When the process terminates, the event is unregistered and subscribers receive termination notifications. If another process later registers the same event name, subscribers must subscribe again.
Some events belong to the node itself, not to any particular process. Application events, health signals, internal buses. You want these events to exist for the entire lifetime of the node, regardless of which process currently publishes. The gen.Node interface provides RegisterEvent for this.
The event's producer is the node core. It survives any publisher process coming and going. A process that restarts continues publishing to the same event after restart. Subscribers are not affected.
Introduced in v3.3.0.
There is a timing problem with process-registered events. If a subscriber's Init() tries to LinkEvent before the producer process has called RegisterEvent, the link fails with gen.ErrEventUnknown. The subscriber then needs retry logic or some other coordination mechanism.
The NodeOptions.Events field registers node-level events before any application is started.
Events declared here are registered as open events with the node as producer. By the time the first application starts, these events already exist. Any process can subscribe from Init() without a race. Any process can publish by name.
If your event requires the token check and you only want a specific process to publish, register it imperatively via node.RegisterEvent(..., gen.EventOptions{Open: false}) and distribute the token through environment variables or process arguments.
Introduced in v3.3.0.
The node runs one node-level event for you, always: gen.CoreEvent. You do not register it and you never publish to it. The node publishes the facts of its own lifecycle there, and any process can subscribe to react to them without polling.
Today the node reports four things on this bus: an application reached the running state, an application stopped (carrying the reason it stopped), a remote node connected, and a remote node disconnected. You subscribe the same way you subscribe to any event, then handle the message types you care about:
A subscriber reacts only to the transitions it cares about and ignores the rest. The bus keeps the last 1000 events, and MonitorEvent returns that buffer on subscription (the example above discards it with _). A subscriber that needs recent history reads those returned events and processes them at its discretion, so a process that subscribes after an application has already stopped still learns of the stop, and a restarted observer does not start blind.
Since events are network-transparent, this bus is at its most useful across nodes. Name a remote node and you watch its lifecycle from anywhere:
A single observer process can subscribe to the gen.CoreEvent of every node in the cluster and learn, in one place and with no polling and no protocol of its own, when each node's applications start and stop and when each node gains or loses a peer. And if a watched node becomes unreachable, the monitor on its event fires with reason gen.ErrNoConnection, so the disappearance of the node is delivered to you through the same subscription.
This bus is local to each node and is available even with networking disabled. It tells you about one node: its own applications and the peers connected to it. Cluster-wide facts, such as a node joining or leaving the cluster or an application's availability across the cluster, are reported separately by the registrar (see Service Discovering).
Events work across nodes seamlessly. A producer on node A can publish events that subscribers on nodes B, C, and D receive. The framework handles the network distribution.
When you subscribe to a remote event, the framework sends a subscribe request to the remote node. The remote node records your subscription. When the producer publishes an event on the remote node, the remote node sends it to all remote subscribers, including you.
If the network connection fails, subscribers receive termination notifications with reason gen.ErrNoConnection. This is consistent with how links and monitors handle network failures for processes.
The buffered events work across nodes too. When you subscribe to a remote event, the remote node sends you the buffered events as part of the subscription response. This catch-up mechanism works regardless of where the producer and subscribers are located.
Event tokens can be delegated. The producer can give its token to another process, allowing that process to publish events under the producer's event registration.
This enables patterns where event generation is separated from event registration. A coordinator registers the event and distributes the token to worker processes. Workers publish events as data becomes available. Subscribers don't know or care which process instance published each event - they just receive events on the registered event name.
Token delegation also allows rotating producers. A primary process registers an event and holds the token. A backup process can take over using the same token if the primary fails. Subscribers see a continuous event stream even as the producing process changes.
Event messages have a specific structure:
Each gen.MessageEvent contains:
Event - The event identifier (name and node)
Message - Your application data (any type)
Timestamp - When the event was published (nanoseconds since epoch)
Subscribers receive these wrapped messages and extract the application data. The wrapping provides context: which event this came from, when it was published, allowing subscribers to handle events from multiple sources or correlate timing.
Each registered event tracks per-event counters: how many messages were published, how many were delivered to local subscribers, and how many were sent to remote nodes. These counters are available through Node.EventInfo and Node.EventRangeInfo.
To query a specific event:
To iterate over all registered events on the node:
Node-level aggregate counters are also available in gen.NodeInfo via node.Info(): EventsPublished (local producer publishes), EventsReceived (events arriving from remote nodes), EventsLocalSent, and EventsRemoteSent.
The Metrics actor automatically exports these counters as Prometheus metrics, along with per-event top-N breakdowns by subscribers, published, local deliveries, and remote sent. It also tracks event utilization state: whether events are actively used, waiting on demand, or idle.
Events fit several common scenarios.
Data streaming - A sensor process registers an event and publishes readings. Multiple monitoring processes subscribe. Each reading goes to all monitors. If a monitor crashes and restarts, it subscribes again and receives recent buffered readings to catch up.
State change notification - A user session process registers an event and publishes state changes (login, logout, permission change). Authorization processes subscribe and update their caches. The session process doesn't track who's interested in its state changes.
System telemetry - Processes publish metrics as events. Monitoring processes subscribe and aggregate. If the monitoring process restarts, buffered events provide recent history to rebuild state.
Workflow coordination - An order processing system publishes order state events. Inventory, shipping, and billing processes subscribe. Each subsystem reacts to relevant state changes. The order process doesn't orchestrate the subsystems - they coordinate through events.
For more information on links and monitors as they apply to processes and nodes, see the Links and Monitors chapter.
case gen.MailboxMessageTypeInspect:
items := message.Message.([]string)
// own state first, the behavior may override any of the fields
result := p.inspect(items...)
for k, v := range p.behavior.HandleInspect(message.From, items...) {
result[k] = v
}
p.SendResponse(message.From, message.Ref, result)func (m *SessionManager) HandleInspect(from gen.PID, item ...string) map[string]string {
if len(item) == 0 {
return m.summary() // totals only, never grows with len(m.sessions)
}
result := map[string]string{}
for _, q := range item {
switch {
case q == "help":
result["help"] = "summary keys: sessions_total, sessions_idle, oldest_age; " +
"queries: session <id>, user <id>, top slowest [n], top oldest [n]"
case strings.HasPrefix(q, "session "):
id := strings.TrimPrefix(q, "session ")
s, ok := m.sessions[id]
if ok == false {
result[q] = "<not found>"
continue
}
result[q] = fmt.Sprintf("user=%s state=%s idle=%s bytes_in=%d",
s.user, s.state, time.Since(s.lastSeen).Round(time.Second), s.bytesIn)
case strings.HasPrefix(q, "user "):
user := strings.TrimPrefix(q, "user ")
ids := m.byUser[user] // pre-indexed, not a scan
result[q] = fmt.Sprintf("sessions=%d %s", len(ids), joinCapped(ids, 20))
case strings.HasPrefix(q, "top "):
result[q] = m.top(strings.TrimPrefix(q, "top "))
default:
result[q] = "<unknown item>" // reported, not silently absent
}
}
return result
}node, err := ergo.StartNode("worker@localhost", gen.NodeOptions{
Network: gen.NetworkOptions{
Flags: gen.NetworkFlags{
Enable: true,
EnableRemoteApplicationStart: true, // allow remote nodes to start apps
},
},
})network := node.Network()
err := network.EnableApplicationStart("workers")
if err != nil {
// handle error
}// Allow only these nodes to start the workers app
network.EnableApplicationStart("workers",
"scheduler@node1",
"scheduler@node2",
)// Add more nodes to the allowed list
network.EnableApplicationStart("workers",
"scheduler@node1",
"scheduler@node2",
"scheduler@node3", // newly allowed
)// Remove specific nodes
network.DisableApplicationStart("workers", "scheduler@node2")// No nodes can start this application remotely anymore
network.DisableApplicationStart("workers")// Re-enable for all nodes
network.EnableApplicationStart("workers") // no node argumentsnetwork := node.Network()
remote, err := network.GetNode("worker@otherhost")
if err != nil {
return err // node unreachable, no route, etc
}err := remote.ApplicationStart("workers", gen.ApplicationOptions{})
if err != nil {
// handle error - not allowed, app not loaded, already running, etc
}// stops when its last group member is gone
err := remote.ApplicationStartTemporary("workers", gen.ApplicationOptions{})
// a member terminating abnormally stops it
err := remote.ApplicationStartTransient("workers", gen.ApplicationOptions{})
// any member terminating stops it
err := remote.ApplicationStartPermanent("workers", gen.ApplicationOptions{})// On the remote node
info, err := node.ApplicationInfo("workers")
// info.Parent == "scheduler@node1" (requesting node name)processInfo, err := node.ProcessInfo(workerPID)
// processInfo.Parent == <scheduler@node1.0.1> (core PID of requesting node)// Worker spawns a child process
childPID, _ := worker.Spawn(factory, options)
childInfo, _ := node.ProcessInfo(childPID)
// childInfo.Parent == workerPID (not the remote core PID)node, err := ergo.StartNode("scheduler@localhost", gen.NodeOptions{
Security: gen.SecurityOptions{
ExposeEnvRemoteApplicationStart: true, // allow env inheritance for remote app starts
},
})type WebService struct {
act.Actor
}
func (w *WebService) Init(args ...any) error {
// Create WebSocket handler
wsHandler := websocket.CreateHandler(websocket.HandlerOptions{
ProcessPool: []gen.Atom{"ws-handler"},
HandshakeTimeout: 15 * time.Second,
EnableCompression: true,
CheckOrigin: func(r *http.Request) bool { return true },
})
// Spawn handler meta-process
_, err := w.SpawnMeta(wsHandler, gen.MetaOptions{})
if err != nil {
return err
}
// Register with HTTP mux
mux := http.NewServeMux()
mux.Handle("/ws", wsHandler)
// Create web server
server, err := meta.CreateWebServer(meta.WebServerOptions{
Host: "localhost",
Port: 8080,
Handler: mux,
})
if err != nil {
return err
}
_, err = w.SpawnMeta(server, gen.MetaOptions{})
return err
}type MessageConnect struct {
ID gen.Alias // Connection meta-process identifier
RemoteAddr net.Addr // Client address
LocalAddr net.Addr // Server address
}func (h *Handler) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case websocket.MessageConnect:
h.connections[m.ID] = ConnectionInfo{
RemoteAddr: m.RemoteAddr,
ConnectedAt: time.Now(),
}
h.Log().Info("Client connected: %s from %s", m.ID, m.RemoteAddr)
}
return nil
}type MessageDisconnect struct {
ID gen.Alias // Connection meta-process identifier
}case websocket.MessageDisconnect:
delete(h.connections, m.ID)
h.Log().Info("Client disconnected: %s", m.ID)type Message struct {
ID gen.Alias // Connection identifier
Type MessageType // Message type (text, binary, ping, pong, close)
Body []byte // Message payload
}
const (
MessageTypeText MessageType = 1
MessageTypeBinary MessageType = 2
MessageTypeClose MessageType = 8
MessageTypePing MessageType = 9
MessageTypePong MessageType = 10
)case websocket.Message:
h.Log().Info("Received from %s: %s", m.ID, string(m.Body))
// Process message, maybe reply
h.SendAlias(m.ID, websocket.Message{Body: []byte("ack")})// Send to specific connection
h.SendAlias(connID, websocket.Message{
Type: websocket.MessageTypeText,
Body: []byte("notification"),
})
// Broadcast to all connections
for connID := range h.connections {
h.SendAlias(connID, websocket.Message{
Body: []byte("broadcast message"),
})
}// Actor on node1 sends to connection on node2
actor.SendAlias(connectionAlias, websocket.Message{
Body: []byte("update from backend"),
})func (c *Client) Init(args ...any) error {
conn, err := websocket.CreateConnection(websocket.ConnectionOptions{
URL: url.URL{Scheme: "ws", Host: "server:8080", Path: "/ws"},
Process: "message-handler",
HandshakeTimeout: 15 * time.Second,
EnableCompression: true,
})
if err != nil {
return err
}
connID, err := c.SpawnMeta(conn, gen.MetaOptions{})
if err != nil {
conn.Terminate(err)
return err
}
c.Log().Info("Connected to server: %s", connID)
return nil
}wsHandler := websocket.CreateHandler(websocket.HandlerOptions{
ProcessPool: []gen.Atom{"handler1", "handler2", "handler3"},
})type PoolBehavior interface {
gen.ProcessBehavior
Init(args ...any) (PoolOptions, error)
HandleMessage(from gen.PID, message any) error
HandleCall(from gen.PID, ref gen.Ref, request any) (any, error)
Terminate(reason error)
HandleEvent(message gen.MessageEvent) error
HandleInspect(from gen.PID, item ...string) map[string]string
}type WorkerPool struct {
act.Pool
}
func (p *WorkerPool) Init(args ...any) (act.PoolOptions, error) {
return act.PoolOptions{
PoolSize: 5, // 5 workers
WorkerFactory: createWorker, // Factory for workers
WorkerMailboxSize: 100, // Limit each worker to 100 messages
WorkerArgs: []any{"config"}, // Args passed to worker Init
}, nil
}
func createPoolFactory() gen.ProcessBehavior {
return &WorkerPool{}
}
// Spawn the pool
poolPID, err := node.Spawn(createPoolFactory, gen.ProcessOptions{})// Rate limit: 5 workers × 20 messages = 100 requests max in flight
return act.PoolOptions{
PoolSize: 5,
WorkerMailboxSize: 20,
WorkerFactory: createAPIWorker,
}, nil// Send a message to the pool
process.Send(poolPID, WorkRequest{Data: "task1"})
// The pool forwards to a worker transparently
// The worker's HandleMessage receives it// Sender
process.Send(poolPID, "hello")
// Worker's HandleMessage
func (w *Worker) HandleMessage(from gen.PID, message any) error {
// 'from' is the original sender's PID, not the pool's PID
w.Send(from, "reply") // Reply goes to original sender
return nil
}// Normal priority - forwarded to worker automatically
process.Send(poolPID, WorkRequest{})
// High priority - handled by pool's HandleMessage
process.SendWithPriority(poolPID, ManagementCommand{}, gen.MessagePriorityHigh)
// Pool's HandleMessage - invoked for Urgent/System messages
func (p *WorkerPool) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case ManagementCommand:
count, _ := p.AddWorkers(msg.AdditionalWorkers)
p.Log().Info("scaled to %d workers", count)
default:
p.Log().Warning("unhandled message: %T", message)
}
return nil
}// Normal priority - forwarded to worker
result, err := process.Call(poolPID, WorkRequest{})
// High priority - handled by pool's HandleCall
stats, err := process.CallWithPriority(poolPID, GetPoolStatsRequest{}, gen.MessagePriorityHigh)
// Pool's HandleCall - invoked for Urgent/System requests
func (p *WorkerPool) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
switch req := request.(type) {
case GetPoolStatsRequest:
// The pool's own counters are unexported. Read them through the
// inspect callback, where they are published as strings.
info := p.HandleInspect(from)
return PoolStats{
Size: info["ergo:pool_size"],
Forwarded: info["ergo:messages_forwarded"],
}, nil
default:
p.Log().Warning("unhandled request: %T", request)
return nil, nil // Caller will timeout
}
}func (p *WorkerPool) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case ScaleUpCommand:
newSize, err := p.AddWorkers(msg.Count)
if err != nil {
p.Log().Error("failed to add workers: %s", err)
return nil
}
p.Log().Info("scaled up to %d workers", newSize)
case ScaleDownCommand:
newSize, err := p.RemoveWorkers(msg.Count)
if err != nil {
p.Log().Error("failed to remove workers: %s", err)
return nil
}
p.Log().Info("scaled down to %d workers", newSize)
}
return nil
}stats, err := node.Inspect(poolPID)
// stats contains:
// - "ergo:pool_size": configured number of workers
// - "ergo:worker_behavior": type name of worker behavior
// - "ergo:worker_mailbox_size": mailbox limit per worker
// - "ergo:worker_restarts": count of workers restarted
// - "ergo:messages_forwarded": total messages forwarded to workers
// - "ergo:messages_unhandled": messages dropped (all workers full)token, err := process.RegisterEvent("price_update", gen.EventOptions{
Notify: true,
Buffer: 10,
})token, _ := process.RegisterEvent("app.events", gen.EventOptions{
Open: true,
Buffer: 50,
})process.SendEvent("price_update", token, PriceUpdate{Symbol: "BTC", Price: 42000})lastEvents, err := process.LinkEvent(gen.Event{Name: "price_update", Node: "node@host"})
for _, event := range lastEvents {
// Process historical events
price := event.Message.(PriceUpdate)
}token, err := node.RegisterEvent("notifications", gen.EventOptions{
Open: true,
Buffer: 100,
})options := gen.NodeOptions{
Events: []gen.NodeEventSpec{
{Name: "notifications", Buffer: 100},
{Name: "audit", Buffer: 10},
},
Applications: []gen.ApplicationBehavior{...},
}
node, err := ergo.StartNode("mynode@localhost", options)func (a *watcher) Init(args ...any) error {
_, err := a.MonitorEvent(gen.Event{Name: gen.CoreEvent})
return err
}
func (a *watcher) HandleEvent(event gen.MessageEvent) error {
switch m := event.Message.(type) {
case gen.MessageCoreApplicationStopped:
a.Log().Warning("application %s stopped: %s", m.Name, m.Reason)
case gen.MessageCoreNodeDisconnected:
a.Log().Warning("node %s disconnected: %s", m.Name, m.Reason)
}
return nil
}a.MonitorEvent(gen.Event{Name: gen.CoreEvent, Node: "worker1@host"})info, err := node.EventInfo(gen.Event{Name: "price_update", Node: "node@host"})
// info.MessagesPublished - total messages published to this event
// info.MessagesLocalSent - messages delivered to local subscribers
// info.MessagesRemoteSent - messages sent to remote subscriber nodes
// info.Subscribers - current subscriber countnode.EventRangeInfo(func(info gen.EventInfo) bool {
fmt.Printf("event %s: published %d, local %d, remote %d\n",
info.Event.Name,
info.MessagesPublished,
info.MessagesLocalSent,
info.MessagesRemoteSent,
)
return true // continue iteration
})The live multi-node harness for testing the real runtime end to end
Some behavior only exists when everything is real. A supervisor restarts a crashed child on its own goroutine. A monitor fires a Down after the process it watched - on another node - dies. A message leaves one node, is serialized, crosses a TCP connection, and lands in a mailbox a few milliseconds later. None of this is decision logic you can step through callback by callback; it is the runtime doing its job, concurrently, in real time. That is the gap stage fills.
The unit harness deliberately removes all of that: it gives one actor a mock node and runs its callbacks by hand, so a test is fast and perfectly deterministic. stage makes the opposite trade. It starts real nodes, runs real actors and applications on them, lets them talk over the real network, and watches what the live runtime actually does. You give up the frozen snapshot and the single-actor focus; in return you can test what only the real runtime exhibits - supervision and restarts, links and monitors across nodes, cross-node messaging, remote spawn, service discovery, disconnects.
Everything else about stage follows from one fact: because the system runs for real and concurrently, you do not inspect a result, you wait for it and observe it. The rest of this page builds that idea up from the smallest possible test.
Every stage test has the same skeleton: create a stage, start a node on it, put a process on the node, make something happen, and assert on it.
stage.New returns a Stage - the owner of every node the test starts - and registers cleanup with the test, so the nodes are stopped automatically when it ends; you never tear them down by hand. s.StartNode starts one live node and returns a handle to it.
That handle, a *stage.Node, is worth a close look, because you work through it for the whole test. It is a thin wrapper around a real gen.Node, not the node itself. It surfaces the operations a test reaches for most - Spawn, SpawnRegister, Send, Call, SendExit, Kill - and it carries the assertion grammar from , which is why you write n.ShouldDeliver(...) straight on it. For anything the wrapper does not cover - any other method of the underlying node - n.Native() returns the real gen.Node
The last line of that test is the one new idea. In unit, an actor has already run by the time you assert, so an assertion reads a finished snapshot. Here the send and its delivery happen on the runtime's own goroutines, and the record of the delivery may not exist the instant you check for it. So Within makes the assertion wait: it polls until the assertion holds or the deadline passes. Almost every stage assertion carries a Within, and the next sections lean on it constantly.
On one node it hardly matters where a happening is recorded - it all lands in that node's journal. The moment there are two nodes it matters a great deal, and answering "which node do I assert on?" is the key to reading and writing stage tests.
The harness observes a node by wrapping it, and what it sees falls into two kinds:
what the node's own processes do - send a message, make a call, spawn a child, set up a link or a monitor. This is egress, and it is recorded on the node the acting process runs on.
what arrives at the node's processes - a message delivered into a mailbox, a Down, an Exit, a subscribed event. This is ingress, and it is recorded on the node that hosts the receiver.
Each node keeps its own journal of both. So one interaction that crosses the network leaves two traces, on two different nodes:
The send is asserted on a, where the pinger runs; the delivery on b, where the ponger runs. That is the whole rule: assert egress on the actor's node, ingress on the recipient's node.
Two smaller things in that test are worth naming. The nodes were never explicitly connected - the first time a addressed a process on b, the runtime looked b up through the registrar and dialed it for you. And every value that crosses the wire must be registered with RegisterType, because the network serializes it; this is where the Native() escape hatch first earns its keep, since type registration is a node-level concern the wrapper does not surface.
This egress/ingress split is the half that cannot show. A mock node has no real delivery to observe, so unit records only egress, and you assert an actor's reaction to an input you fed it. On a live node the delivery is real, so stage records the ingress directly - which is why the ingress assertions ShouldDeliver, ShouldReceiveDown, and ShouldReceiveEvent live here and not there.
You have seen Within on every cross-node assertion, and the reason is the trade we started with: the runtime is concurrent, so a happening you expect may not be recorded yet when you assert. Within turns the assertion into a bounded wait. A positive assertion - Once, Times, AtLeast - succeeds the instant its condition is met, so the wait usually costs only the real latency of the action.
A negative is the case that needs care. To claim something did not happen, you must give it time to fail to happen: None().Within(...) watches for the whole window and passes only if nothing matched it.
Because a live test runs in phases and the same action can recur, scope an assertion to one phase with Mark and Since: Mark records the current position in the journal, Since restricts the next assertion to what came after it. This is how you prove "no second event after the legitimate one" without the first occurrence spoiling the count:
Two finishing choices. End with Must instead of Assert when the test cannot continue meaningfully without this step - a cross-node test that proceeds past a connection that never came up only produces noise; Assert reports the failure and lets the test go on. And note the one place you do not wait: a synchronous Call blocks for its reply and hands it back directly, so you check its return value with check.Equal, no Within involved.
The grammar itself - ShouldX, the cardinalities, Within, Mark, Since, Must - is the shared vocabulary documented in ; stage only supplies the live nodes it runs against.
A natural thing to test is that a process stopped - and here stage works differently from unit in a way that reveals its whole philosophy. Stage does not hand you a "terminated" record. It records what the runtime really does at its seams, and a process ending is not a message on a wire; it is something other processes learn about through the mechanisms the framework already provides - a monitor's Down or a link's Exit. So you observe a stop the way the rest of the system does: watch the process, end it, and assert the notification.
The same principle draws the line between stage and unit for two more things. A SendAfter timer is not recorded as a scheduled action - it fires for real, and you observe the resulting send or delivery once it does. And the node logger is turned off in a stage, so there are no log records to assert on. A termination reason on its own, a scheduled-send record, a log line: those are facts a harness has to synthesize, and synthesizing is unit's job. Stage shows you only what genuinely happened.
You saw that nodes connect themselves on first contact, so s.Connect is never required just to make traffic flow. You reach for it deliberately, in two cases.
The first is to test connectivity itself. s.Connect(a, b) dials immediately and waits, deterministically, until both sides have registered the link before returning - so the test asserts that two nodes can reach each other, by a direct call, instead of inferring it from an application message that happened to get through.
The second is remote operations. Connect returns the peer as a gen.RemoteNode, and stage gives back a wrapped one whose Spawn, SpawnRegister, and application-start calls are recorded on the initiating node's journal - which is exactly what the next section relies on.
A node will not let a stranger start processes on it: remote operations are denied by default. The target opens the door in two steps - it allows the specific factory with EnableSpawn and enables remote spawn in its network flags - and only then does a spawn issued across the connection succeed. It is recorded as remote egress on the node that initiated it:
EnableApplicationStart is the application-level counterpart of EnableSpawn, and the same remote handle's application-start calls are recorded the same way.
Discovery is what let the two nodes find each other earlier, and you can configure it. By default a stage runs a private in-memory registrar: it needs no ports, is isolated to that one stage, and so any number of stages run in parallel without colliding. It serves node routes and enforces name uniqueness, matching the embedded registrar a bare node ships with.
Some applications do more than route between nodes - they discover applications and react to a registrar event stream. RegistrarFull upgrades the in-memory registrar to serve ResolveApplication and emit the canonical registrar events, the same contract etcd and Saturn implement:
To test against a real backend, set StageOptions.Registrar to a factory - etcd's, for instance. It is called once per node, so every node gets its own registrar instance over the one backend.
stage.NodeOptions carries what a real node needs: Applications to load, Env, a Cookie, the network knobs (MaxMessageSize, FragmentSize, NetworkFlags, PoolSize, Mode), and Security.
Two of those model shapes you cannot reach otherwise. Mode: gen.NetworkModeHidden gives a node that dials out but runs no acceptor - a node behind NAT, which peers cannot dial back, and the only way to test that asymmetry. PoolSize sets the number of TCP connections per peer, which is what a test about per-sender ordering or a degraded pool needs. There is also DisableSystemManage, which keeps the system application's mutating plane down, modelling a node whose state nothing may change from outside.
Loading an application is how you test framework-spawned, supervised, name-registered processes end to end - the very processes a bare Spawn cannot give you:
By default a node starts bare, with no system processes, so a test can assert exact process and application counts; add the system services with NodeOptions{EnableSystemApp: true} when one is needed.
For more than two nodes, s.ConnectMesh(nodes...) connects every pair at once - exercising the simultaneous-connect collision handling a real cluster meets under a connect storm - and waits until every node sees every other with its TCP connection pool fully filled before returning. n.Kill force-terminates a process, and, as the first test noted, the stage stops every node it started on cleanup, so a test never leaks a running node.
You now have both halves of the testing story. freezes one actor against a mock node and reads a snapshot: it is fast, fully deterministic, and it models the things stage leaves to the real runtime - termination reasons, scheduled sends, log lines. Stage runs the real system and observes it live: it is the only way to test supervision and restarts, links and monitors across nodes, cross-node messaging, remote spawn, service discovery, and disconnects, and it pays for that with concurrency you wait on rather than control.
A healthy suite uses both, and the division is clean. Test an actor's decision logic - what it does with a message, how it reacts to a failure, what it spawns - in unit, where most of your tests should live. Reserve stage for behavior that only emerges when the runtime, the network, and more than one node are all real. Both speak the same assertion grammar, , so a test reads the same whichever layer it runs on.
Running nodes behind NAT or load balancers
When a node starts, it registers its routes with a registrar. A route contains connection parameters: port number, TLS flag, handshake version, protocol version, and optionally a host address. When another node needs to connect, it resolves the target node's routes from the registrar and uses these parameters to establish a connection.
The host address in the route is optional. When empty, the connecting node extracts the host from the target's node name. If you're connecting to worker@10.0.1.50, the framework extracts 10.0.1.50 and connects to that address on the resolved port.
This works when node names reflect reachable addresses. But when a node is behind NAT, its node name contains a private IP that external nodes can't reach. The solution is to include a public address in the route itself using RouteHost and RoutePort.
Understanding the resolution flow clarifies why NAT causes problems and how RouteHost
In a cluster, processes on different nodes need to find each other by name, know when one appears or disappears, and broadcast to a set of interested processes that changes over time. Building this by hand means maintaining monitors between nodes, tracking which node hosts what, and agreeing on a naming scheme, all while nodes join and leave.
Grid provides three capabilities as an application: a distributed registry that maps a key to a single owner process, lifecycle monitors that notify you when keys appear, change, or vanish, and process groups for per-key publish/subscribe.
Grid runs as an application on your node. Every node keeps a full local copy of the registry, so a lookup is a local read that returns immediately. Writes are serialized per key by a shard actor and replicate to peer nodes in the background. Grid is AP and eventually consistent: it stays available during a partition and converges when the partition heals, at the cost of brief windows where two nodes may disagree.
Every node that starts Grid with the same Domain discovers the others and forms a mesh - through the registrar, already-connected nodes, or static Peers. Once the node is up, any actor on it uses the registry, monitors, and groups through the grid package. No wiring between nodes is required.
s := stage.New(t)
n := s.StartNode("n")
ponger := n.Spawn(factoryPonger, gen.ProcessOptions{}) // a process that accepts messages
n.Send(ponger, ping{Seq: 1})
n.ShouldDeliver().To(ponger).Message(ping{Seq: 1}).Once().Within(time.Second).Assert()s := stage.New(t)
a, b := s.StartNode("a"), s.StartNode("b")
// a value that crosses the wire must be registered for transport, on both nodes;
// that registration lives on the node's Network, reached through Native()
a.Native().Network().RegisterType(ping{})
b.Native().Network().RegisterType(ping{})
ponger := b.Spawn(factoryPonger, gen.ProcessOptions{})
pinger := a.Spawn(factoryPinger, gen.ProcessOptions{}) // on a sendPing trigger, sends a ping to the target
a.Send(pinger, sendPing{To: ponger, Seq: 1})
a.ShouldSend().From(pinger).Message(ping{Seq: 1}).Once().Within(time.Second).Must() // egress, on a
b.ShouldDeliver().To(ponger).Message(ping{Seq: 1}).Once().Within(time.Second).Must() // ingress, on bb.ShouldDeliver().To(ponger).Message(ping{Seq: 2}).None().Within(150 * time.Millisecond).Assert()m := n.Mark()
n.ShouldReceiveDown().To(w).About(target).Since(m).None().Within(150 * time.Millisecond).Assert()resp, err := a.Call(ponger, pingRequest{Seq: 7})
check.NoError(t, err)
check.Equal(t, pong{Seq: 7}, resp)target := n.Spawn(factoryPonger, gen.ProcessOptions{})
w := n.Spawn(factoryWatcher, gen.ProcessOptions{}, target) // watcher monitors target in its Init
n.ShouldMonitor().From(w).Target(target).Once().Within(time.Second).Must() // egress: the monitor was set up
n.Kill(target)
n.ShouldReceiveDown().To(w).About(target).Reason(gen.TerminateReasonKill).
Once().Within(time.Second).Must() // ingress: the watcher's Downremote := s.Connect(a, b) // dials now, waits for both sides, returns a's view of bb := s.StartNode("b", stage.NodeOptions{NetworkFlags: gen.NetworkFlags{Enable: true, EnableRemoteSpawn: true}})
b.EnableSpawn("worker", factoryWorker)
remote := s.Connect(a, b)
remote.Spawn("worker", gen.ProcessOptions{})
a.ShouldRemoteSpawn().To(b.Name()).Name("worker").Once().Within(time.Second).Assert()s := stage.New(t, stage.StageOptions{RegistrarFull: true})
n := s.StartNode("n")
sub := n.Spawn(factoryRegSub, gen.ProcessOptions{}) // subscribes to the registrar event in its Init
mk := n.Mark()
reg, _ := n.Native().Network().Registrar()
reg.RegisterApplicationRoute(gen.ApplicationRoute{Name: "myapp", Node: n.Name(), State: gen.ApplicationStateRunning})
n.ShouldReceiveEvent().To(sub).Where(func(e check.Event) bool {
m, ok := e.Message.(gen.MessageRegistrarApplicationStarted)
return ok && m.Route.Name == "myapp"
}).Since(mk).Once().Within(time.Second).Must()a := s.StartNode("a", stage.NodeOptions{Applications: []gen.ApplicationBehavior{createApp1()}})
service1, err := a.ProcessPID("service1") // the application registered this process by nameWhen a node registers with any registrar (embedded, etcd, or Saturn), it sends its routes:
The registrar stores these routes exactly as received. When another node resolves worker@10.0.1.50:
The connecting node checks if route.Host is set. If empty, it extracts the host from the node name as a fallback.
When a node is behind NAT, its node name contains a private IP. The external node resolves routes, gets an empty host, extracts 10.0.1.50 from the node name, and tries to connect to a private IP that's unreachable from the internet.
Tell the node what address to advertise by setting RouteHost and RoutePort in AcceptorOptions:
Now the route registered with the registrar includes the public address:
When another node resolves:
The connecting node sees a non-empty Host in the route and uses it directly. No fallback to node name extraction. The connection goes to the public address, NAT forwards it, and the connection succeeds.
Host
Network interface to bind the listener socket
Port
TCP port to listen on
RouteHost
Host and RouteHost are independent:
Host: "0.0.0.0" binds to all interfaces but is useless as a connectable address
RouteHost: "203.0.113.50" is what other nodes use to connect
All registrars (embedded, etcd, Saturn) handle routes identically:
Registration: Store routes exactly as provided, including Host field
Resolution: Return routes exactly as stored
Connection: Connecting node uses route.Host if set, otherwise extracts from node name
The embedded registrar sends resolution queries via UDP to the host portion of the node name. For worker@10.0.1.50, it queries 10.0.1.50:4499. This works because the registrar query goes to the private network (where the registrar runs), not to the NAT-ed node directly.
External registrars (etcd, Saturn) use their central server for all queries. The node name's host portion is irrelevant for resolution since queries go to etcd/Saturn, not to the target host.
NAT forwards the same port (15000 external = 15000 internal):
NAT maps different ports (32000 external -> 15000 internal):
Advertise a DNS name for flexibility:
The DNS name is stored in the route. Connecting nodes resolve DNS at connection time, getting the current IP.
Pod behind NodePort service:
Setting RouteHost affects all nodes that resolve your address, including nodes on the same local network. If local nodes should use internal addresses while external nodes use public addresses, you have several options.
Run acceptors on different ports for internal and external access:
Both routes are registered. Local nodes can connect via either. External nodes can only use the one with RouteHost set.
Configure local nodes to bypass registrar resolution:
Static routes are checked before registrar resolution. Local nodes use the static route (internal IP), external nodes use registrar resolution (public IP from RouteHost).
Hairpin NAT (also called NAT loopback) allows internal nodes to connect using the public IP address.
When you set RouteHost: "203.0.113.50", all nodes - including local ones - receive this public address from the registrar and try to connect to it.
Without hairpin NAT support:
With hairpin NAT support:
The traffic makes a "hairpin turn" at the NAT device - goes toward the external interface, turns around, comes back to the internal network.
This is a network infrastructure configuration on your router/firewall, not an application change. Check your NAT device documentation for "hairpin NAT", "NAT loopback", or "NAT reflection" settings.
RouteHost/RoutePort and static routes solve opposite problems:
You're behind NAT, others can't reach you
Set RouteHost/RoutePort to advertise your public address
Others are behind NAT, you can't reach them
Configure static routes with their public addresses
In complex topologies, you might use both. Your node advertises its public address via RouteHost. It also configures static routes to reach other nodes through specific gateways.
External nodes can't connect
Verify NAT/firewall forwards traffic to your node
Check RouteHost and RoutePort match your NAT configuration
Confirm the public address is reachable from outside
Local nodes unnecessarily using public address
Expected when RouteHost is set. Use multiple acceptors or static routes to give local nodes a direct path.
Wrong port advertised
If using PortRange and the first port is unavailable, the node binds to a different port. RoutePort (if set) still advertises your configured value. Ensure NAT forwards to the actual bound port, or ensure your configured port is available.
Embedded registrar resolution fails for cross-network nodes
The embedded registrar sends UDP queries to hostname:4499 extracted from the target node name. If worker@10.0.1.50 is behind NAT, external nodes send UDP to 10.0.1.50:4499, which is unreachable. Use external registrars (etcd, Saturn) for cross-network deployments, or configure static routes.
// What gets registered (simplified)
MessageRegisterRoutes{
Node: "worker@10.0.1.50",
Routes: []gen.Route{
{
Host: "", // empty by default
Port: 15000,
TLS: false,
HandshakeVersion: ...,
ProtoVersion: ...,
},
},
}node, err := ergo.StartNode("worker@10.0.1.50", gen.NodeOptions{
Network: gen.NetworkOptions{
Acceptors: []gen.AcceptorOptions{
{
Host: "0.0.0.0", // listen on all interfaces
Port: 15000, // listen on this port
RouteHost: "203.0.113.50", // advertise this host
RoutePort: 32000, // advertise this port
},
},
},
})// What gets registered
Routes: []gen.Route{
{
Host: "203.0.113.50", // from RouteHost
Port: 32000, // from RoutePort
TLS: false,
HandshakeVersion: ...,
ProtoVersion: ...,
},
}Acceptors: []gen.AcceptorOptions{
{
Host: "0.0.0.0",
Port: 15000,
RouteHost: "203.0.113.50",
// RoutePort not set - uses actual port 15000
},
}Acceptors: []gen.AcceptorOptions{
{
Host: "0.0.0.0",
Port: 15000,
RouteHost: "203.0.113.50",
RoutePort: 32000,
},
}Acceptors: []gen.AcceptorOptions{
{
Host: "0.0.0.0",
Port: 15000,
RouteHost: "worker.prod.example.com",
},
}Acceptors: []gen.AcceptorOptions{
{
Host: "0.0.0.0",
Port: 15000, // container port
RouteHost: os.Getenv("NODE_IP"), // Kubernetes node IP
RoutePort: 32000, // NodePort
},
}Acceptors: []gen.AcceptorOptions{
{
Host: "10.0.1.50", // internal only, no RouteHost
Port: 15000,
},
{
Host: "0.0.0.0",
Port: 15001,
RouteHost: "203.0.113.50",
RoutePort: 32000,
},
}// On local nodes
route := gen.NetworkRoute{
Route: gen.Route{
Host: "10.0.1.50",
Port: 15000,
},
}
network.AddRoute("worker@10.0.1.50", route, 100)Domain
"default"
Peering scope. A node peers only with grids of the same domain. The Ergo application name is grid_<Domain>, so several independent grids can coexist on one node.
Shards
8
Each node keeps a full local copy of the registry. A key is hashed into one of Shards buckets, and one shard actor per bucket is the sole writer for that slice of the keyspace on the node:
Because a single actor owns each slice, writes to a key are serialized without locks. When a shard applies a local write it replicates it to the counterpart shard - the same index - on every peer node. Replication is asynchronous and fire-and-forget: Register returns as soon as the local shard has recorded the entry, and peers converge shortly after.
Reads never touch a shard actor. Lookup and the counts read the node's local copy directly, so they are cheap and lock-free. The consequence is the AP contract: what you observe is the node's converged view, which may briefly lag a write made on another node; what happens underneath is background replication that brings every node to the same state. If you need a read to reflect the very latest cluster-wide write with no lag, grid is not the tool - it trades that guarantee for availability and speed.
Register claims a key for the calling process. The owner is the caller's PID, and the meta value is arbitrary data carried alongside the entry.
Register is synchronous - it routes to the owning shard and returns an error. If a different, live process already owns the key it returns gen.ErrTaken. Registering the same key again as its current owner is idempotent when the metadata is unchanged; when the metadata differs, the entry is updated and monitors are notified. Unregister removes a key, but only if the caller owns it locally - otherwise it returns gen.ErrUnknown (no such key) or gen.ErrIncorrect (not the owner). When the owner process terminates, its keys are removed automatically.
Lookup reads the local view and returns the owner, the metadata, and whether the key exists:
RegistryCount returns the size of the local view, which converges to the cluster-wide total. LocalRegistryCount and LocalEntries return only the keys owned by the calling node, which is what you want for handoff, draining, or inspection.
Because grid is AP, two nodes can register the same key during a partition, or at nearly the same instant. When their writes meet, grid resolves the conflict deterministically, last-writer-wins:
The registration timestamp decides; ties break by owner PID, then by node name, so every node picks the same winner without coordination. The losing owner is stopped with ErrRegistryConflict, delivered as an exit signal, and the winner's entry is re-replicated to heal any peer that still holds the loser. After convergence the key has exactly one owner cluster-wide.
This is a deliberate trade-off. Last-writer-wins keeps the registry available and self-healing with no consensus round, but it means a registration you thought succeeded can later be revoked, and the process that lost is terminated. Grid's registry is a fast observation and coordination layer, not a distributed lock. If your application requires at-most-one ownership with no window of divergence - for example, exclusive control of an external resource - use grid to observe and route, and pair it with a linearizable authority for the actual exclusivity.
The conflict exit comes from a shard actor, not from the loser's parent, so it is an ordinary exit request. An owner with SetTrapExit(true) receives it as a message and can carry on running. Grid does not try again. You are left with a process that still believes it owns the key while every registry in the cluster names someone else.
So do not enable the trap on a process that registers keys. If it is on for other reasons, terminate as soon as the conflict arrives. The one signal a trap cannot refuse comes from the parent, which is why a locally supervised owner is easier to reclaim than a remote one - see Remote Spawn Process.
The second rule is about state. A losing owner usually writes its state back on the way out, and by then the winner has already loaded that state and carried on from it. The loser's flush overwrites the winner's work, and the symptom appears much later as a lost update. Check the reason first. It arrives wrapped, so compare it with errors.Is:
If the write cannot be skipped, let storage decide instead. Version the row and update it conditionally, so a stale owner's write matches nothing. The Leader actor documentation shows that pattern in full.
Grid cannot supply the version for you. It orders conflicts by registration time, and clocks on separate nodes do not form a monotonic sequence.
Monitors tell an actor when keys change. You subscribe to an exact key, a prefix, or the whole domain, and notifications arrive in HandleMessage.
On subscribe you first receive a MessageRegistered for every matching key already present, then live changes as they happen. This snapshot-then-stream behaviour lets an actor build its view in one place: whatever exists now arrives as if it had just been registered. Subscriptions are keyed by scope, so re-subscribing the same scope is a no-op, and they survive a shard restart.
The three subscription functions differ in scope and reach:
MonitorKey
one exact key
the key's owning shard
MonitorPrefix
a key and everything below it
MonitorPrefix matches at Separator boundaries rather than by raw bytes. With the default separator /, MonitorPrefix("order") matches order and order/42 but not order42 or orders/1. You do not need a trailing separator; if you supply one, it is honoured as given.
MessageUnregistered carries a Reason so a consumer can tell an orderly removal from a failure:
ReasonUnregister
the owner called Unregister
ReasonDown
the owner process terminated
ReasonConflict
Cancel a subscription with DemonitorKey, DemonitorPrefix, or DemonitorAll, passing the same scope you monitored. When a subscribing process terminates, its subscriptions are dropped automatically.
A group is per-key publish/subscribe layered on the Ergo event bus. The process that owns the key opens a group; other processes join it to receive broadcasts. The owner broadcasts with Dispatch, and payloads arrive at members in HandleEvent.
Join resolves the owner's node from the local registry, subscribes to the group event there, and returns the event handle to pass to Leave. Because a group is hosted by its owner, Dispatch and MemberCount must run on the owner node. A group lives exactly as long as its owner: when the owner terminates or its node leaves, members receive a gen.MessageDownEvent. The recovery pattern is to re-Join once the key reappears in the registry - the object has moved, and members follow it.
Groups report a member count but do not enumerate members, and membership is not replicated as registry state. When you need a roster, per-member join and leave events, or membership that outlives the owner, build that on grid's registry and monitors rather than on the group event.
Each shard discovers and connects to its counterpart on other nodes on its own. It draws candidate nodes from three sources: the static Peers list, the nodes already connected to this one, and the registrar's answer for the application grid_<Domain>. It then handshakes with the counterpart shard of the same index. A peer is accepted only if it agrees on the domain, the shard index, and the shard count; a mismatch is logged and refused.
Static seeds are retried until they answer - quickly at first, then at a slower steady interval - so a seed that starts later still joins the mesh. When a peer's node disconnects or its grid application stops, the shard purges that node's keys from its local view and notifies monitors with ReasonNodeDown. When a peer connects, the two shards exchange an authoritative snapshot of the keys each owns; the snapshot reconciles deletes as well as additions, without clobbering entries that are newer than it, so a rejoining node cannot resurrect a key that was already removed.
Registry metadata and group payloads cross the network. A non-primitive meta value passed to Register, and any custom type passed to Dispatch, must be registered for the framework's encoding (EDF) by the consumer application, exactly as for any other message that travels between nodes. Primitive values need no registration.
Grid is a convenience layer over primitives the framework already provides: process registration, MonitorPID and event links, and gen.Event publish/subscribe. It bundles them into a cluster-wide, sharded, self-healing package so you do not assemble discovery, cross-node monitoring, and broadcast by hand for the common case.
Reach past grid to those primitives when your requirements fall outside its trade-offs: a linearizable authority when you need strict single ownership rather than eventual convergence, gen.Event directly when a group needs an enumerable roster or per-member events, or MonitorPID directly for a single well-known process where a distributed registry is more than you need.
import (
"ergo.services/application/grid"
"ergo.services/ergo"
"ergo.services/ergo/gen"
)
func main() {
node, err := ergo.StartNode("mynode@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
grid.CreateApp(grid.Options{Domain: "grid", Shards: 8}),
},
})
if err != nil {
panic(err)
}
node.Wait()
}grid.Options{
Domain: "grid", // peering scope; application name is grid_<Domain>
Shards: 8, // keyspace shard count; all nodes must agree
Separator: "/", // key hierarchy separator for MonitorPrefix
Peers: []gen.Atom{"a@host"} // static seeds for discovery without a registrar
}Register("order/42") on node A
fnv32a("order/42") % Shards = 3
│
▼
shard_3 @ A ──replicate──▶ shard_3 @ B
(sole writer) shard_3 @ Cfunc (w *worker) Init(args ...any) error {
if err := grid.Register(w, "grid", "order/42", "meta-v1"); err != nil {
return err
}
return nil
}if pid, meta, ok := grid.Lookup(w, "grid", "order/42"); ok {
w.Log().Info("order/42 owned by %s (%v)", pid, meta)
}winner = later Time → higher PID.ID → higher PID.Creation → greater Nodefunc (e *entity) Terminate(reason error) {
if errors.Is(reason, grid.ErrRegistryConflict) {
return // the key is someone else's now, our state is stale
}
e.flush()
}func (o *observer) Init(args ...any) error {
return grid.MonitorPrefix(o, "grid", "order")
}
func (o *observer) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case grid.MessageRegistered:
o.Log().Info("%s registered at %s", m.Key, m.Owner)
case grid.MessageUpdated:
o.Log().Info("%s meta changed to %v", m.Key, m.Meta)
case grid.MessageUnregistered:
o.Log().Info("%s gone: %s", m.Key, m.Reason)
}
return nil
}// owner: opens a group for the key it owns and broadcasts to it
func (w *worker) Init(args ...any) error {
grid.Register(w, "grid", "room/42", nil)
return grid.OpenGroup(w, "grid", "room/42")
}
func (w *worker) HandleMessage(from gen.PID, message any) error {
return grid.Dispatch(w, "grid", "room/42", message)
}
// member: joins when it sees the key, receives dispatches in HandleEvent
func (m *member) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case grid.MessageRegistered:
m.joined, _ = grid.Join(m, msg.Domain, msg.Key)
}
return nil
}
func (m *member) HandleEvent(message gen.MessageEvent) error {
switch message.Message.(type) {
case gen.MessageDownEvent:
// the group's owner went away; re-Join once the key reappears
default:
m.Log().Info("broadcast: %v", message.Message)
}
return nil
}Build, run, and diagnose multi-agent AI systems on Ergo Framework
Modern AI systems are multi-agent by nature. A research agent delegates to an analysis agent. A planner coordinates with executors. A conversation manager spawns short-lived task agents. Moving from a demo with a handful of agents to a production deployment with hundreds or thousands surfaces the same problems that distributed systems have solved for decades: crash isolation, supervision, cross-node coordination, observability at scale.
Ergo was built for telecom workloads where these requirements are baseline. AI agents have the same profile: many concurrent isolated workers with fault tolerance, coordination, and real-time behavior. This page shows how to use Ergo as runtime for your agents and as a live diagnostic surface for the running system.
Four problems appear as soon as you move AI agents out of a notebook:
Agent crashes. One stuck LLM call or panicking tool handler takes down the whole process. Everything running in that process dies with it.
Coordination. Agents need to talk to each other. Without a framework this becomes a web of channels, shared state, and custom routing code.
Observability. You can't see what's happening inside a running agent system. Mailbox depth, per-agent CPU, which agents are waiting on which external calls, where cascade failures originate.
Scaling. Distributing agents across nodes requires rethinking addressing, message delivery, and failure semantics.
Ergo addresses all four: isolated processes with supervision, named event streams for coordination, a built-in MCP diagnostic surface, and network-transparent PIDs. The design choices were made for telecom-class distributed systems. The fit to AI workloads is incidental and exact.
An AI agent in Ergo is just an actor: a process with private state and a mailbox, handling messages sequentially.
What you get automatically:
Crash isolation. A panicking LLM call or tool handler terminates only this actor. See .
Supervision. Put the agent under a supervisor and it restarts on failure with your chosen strategy. See .
Distributed addressability. Each agent has a PID that works across nodes. See .
The actor's private state (notes in the example) is safe without any synchronization. Messages arrive one at a time. The actor never shares memory with anyone.
Run N identical worker agents and distribute incoming tasks across them. Ideal for stateless agents that process requests in parallel (LLM calls, embedding lookups, tool invocations).
Pool size and worker mailbox size together bound the work held: PoolSize tasks running, plus PoolSize × WorkerMailboxSize queued behind them. Beyond that the pool drops rather than queues, without telling the sender. See .
When agents specialize by task type (research, analysis, code generation, summarization), route each incoming task to the right agent by content. Unlike a pool of identical workers, a router owns named slots of different agent types and dispatches by inspecting the message.
For sharded stateful agents (per-user conversation memory, per-tenant context), use hash-based routing into a fixed set of slots. Compose with act.Supervisor per slot if you need restart limits and mailbox preservation across worker crashes. See for the full pattern catalogue.
Chain agents by sending from one stage to the next. Each stage runs under a supervisor. Failure in any stage is isolated and restarted.
If AnalysisAgent crashes, the supervisor restarts it without affecting the other stages. Pipelines compose naturally with pools: a stage can be a single actor or a pool of identical workers.
Spawn agents on specific nodes and address them with the same API as local agents.
See for the security model and application-level inheritance.
Agents communicate through named event streams. One producer, any number of subscribers on any nodes.
The framework delivers one network message per subscriber node regardless of how many subscribers that node has. 1M subscribers across 10 nodes cost 10 network messages, not 1M. See and .
AI agents are nondeterministic. Behavior depends on prompts, external API latency, model temperature, and tool responses. Predefined metrics cover known failure modes, but the interesting failures are the ones you didn't anticipate.
Add the application to your node. One listener serves the web UI, the browser API and the MCP surface:
Connect Claude Code (or any MCP-compatible client):
Now you describe a symptom in plain English and the AI works the live system:
Nothing in that sequence was decided in advance, which is the difference between an agent and a dashboard. Each step picked the next from what the previous one said.
The surface has two halves. A resource is a reading with an address: ergo://<node>/<lens>, read again with since= to take only what has landed since. A tool is an act that answers once: 38 of them, from a process listing to a heap profile, plus two that reach the whole cluster - one putting a single question to many nodes, the other a different question to each. Answers explain their own numbers, so the agent needs no table of field meanings that drifts from the code, and a reading that cannot be taken is refused with a reason rather than answered with emptiness.
Only the node serving MCP needs the Observer application. Every node runs the built-in system application that answers these questions, so one endpoint covers the cluster.
What an agent may do is bounded the same way a person is: capability ceilings per deployment, per listener and per caller, so a read-only surface refuses a kill instead of hiding it. Two tools cost the observed node a stop-the-world pause, and the surface says so up front.
For the surface in practice, see ; for configuring and bounding it, .
Add Observer to the generated node setup, which brings the MCP surface with it:
Connect your AI assistant and start investigating:
Running agents across AWS, GCP, Azure, or bare metal is supported via , a managed overlay network that connects nodes without VPNs, proxies, or tunnels. End-to-end encrypted. Currently available via waitlist.
for the actor lifecycle
for restart strategies
for pub/sub coordination
for AI-driven investigation of a live cluster
Logging system and logger implementations
Understanding what happens inside a running system requires logging. But logging in distributed actor systems isn't straightforward. Messages pass between dozens of processes. Processes spawn dynamically, handle requests, and terminate. Network connections form and break. Following a single request's path through the system means tracking its journey across multiple processes, possibly across multiple nodes.
Traditional logging compounds the problem. Each component writes to its own log. Process logs go to one file, network logs to another, node events to a third. When something goes wrong, you're piecing together a timeline from scattered sources, correlating by timestamp and hoping you've found all the relevant entries. It's detective work when you need diagnostic clarity.
Ergo Framework centralizes the logging flow while keeping distribution flexible. Every log call - whether from a process, meta process, or the node itself - flows through a single logging system. That system distributes messages to registered loggers based on configurable filters. One logger might write everything to the console. Another might write only errors to a file. A third might send metrics to a monitoring system. The architecture is simple: centralized input, filtered distribution to multiple outputs.
When code calls process.Log().Info("message"), the framework creates a gen.MessageLog structure. This contains the timestamp, severity level, source identifier, message format and arguments, and any attached structured fields. The message enters the node's logging subsystem.
The subsystem maintains loggers organized by severity level. Each logger, when registered, declares which levels it handles - perhaps just errors and panics, perhaps everything from debug upward. When a log message arrives, the subsystem looks up which loggers are registered for that message's level and calls their Log methods.
This is fan-out distribution. A single info-level message goes to every logger registered for info level. The default logger writes it to stdout. A file logger appends it to a file. A metrics logger counts it. Each logger receives the same message and processes it independently.
Hidden Loggers (introduced in v3.2.0) - Prefix a logger name with "." to create a hidden logger that's excluded from fan-out. Hidden loggers only receive logs from processes that explicitly call SetLogger(name). This creates truly isolated logging streams - bidirectional isolation. For example, register ".debug" as a hidden logger, then have a specific process use SetLogger(".debug"). That process's logs go only to the hidden logger (not to other loggers), and the hidden logger receives logs only from that process (not from fan-out). This is useful for separating verbose debugging output or creating per-process log files without mixing logs from other processes.
You can also use SetLogger("filename") to send a process's logs to a specific logger. The process's logs go only to that logger, but the logger still receives fan-out logs from other processes. This routes verbose process logs to a dedicated destination but doesn't create isolation - the logger sees both the process's logs and system-wide fan-out.
The framework provides six severity levels, ordered from most to least verbose:
gen.LogLevelTrace - Framework internals, message routing, network packets. Extremely verbose, intended only for deep debugging of the framework itself.
gen.LogLevelDebug - Application debugging information. Useful during development but typically disabled in production.
gen.LogLevelInfo - Normal informational messages. This is the default level. Startup events, request handling, normal operations.
gen.LogLevelWarning - Conditions that merit attention but don't prevent operation. Deprecated API usage, approaching resource limits, retry scenarios.
gen.LogLevelError - Errors that prevent specific operations but don't crash the system. Failed requests, unavailable resources, validation failures.
gen.LogLevelPanic - Recovered panics inside actor callbacks. The highest severity marker.
Setting a level creates a threshold. Set a process to gen.LogLevelWarning and it logs warnings, errors, and panics, but suppresses info, debug, and trace. Each level implicitly includes all higher severity levels.
Two special levels control behavior rather than representing severity:
gen.LogLevelDefault - Sentinel meaning "inherit." Nodes with this level become gen.LogLevelInfo. Processes with this level inherit from their parent, leader, or node. This default-then-inherit pattern allows hierarchical log level configuration.
gen.LogLevelDisabled - Stops all logging from the source. The framework doesn't even create log messages. Use this to completely silence a source without removing loggers.
One more constant exists and is worth knowing about only so it does not surprise you: gen.LogLevelSystem. It sits below Trace, prints as system, and is included in gen.DefaultLogLevels, so a logger built from the defaults subscribes to it. Nothing in the framework writes at that level - gen.Log has no method for it - so it is reserved capacity rather than a level you can use or expect to see.
Trace deserves special mention. It's so verbose that enabling it accidentally could flood storage. You can't enable it dynamically via SetLevel. It must be set at startup through gen.NodeOptions.Log.Level or gen.ProcessOptions.LogLevel. This restriction prevents operational mistakes.
Panic also deserves explanation. The framework recovers Go panics that occur inside actor callbacks and logs them at this level. A nil pointer dereference in HandleMessage, a failed type assertion in HandleCall, an index out of bounds in Init are all structural problems in actor code, not operational failures. Logging them at Panic level separates them from the business and technical errors you log at Error level. The framework catches these so your node keeps running, but the Panic log entry tells you something in your code needs fixing. Note that Go's standard library log.Panic() actually triggers a panic, while Ergo's Log().Panic() simply logs at the Panic severity level without panicking. If you are building actors with act.Actor, act.Supervisor, or act.Pool, you won't need to log at this level yourself. The framework handles it. It becomes relevant only if you implement an actor directly through the gen.ProcessBehavior
The node starts at gen.LogLevelInfo. Processes inherit this unless their spawn options specify otherwise. After startup, you can adjust a process's level dynamically with SetLevel, allowing surgical verbosity changes during debugging.
The logging subsystem differentiates between five source types: node, process, meta process, network, and application. Each carries its source information in a typed structure - gen.MessageLogNode, gen.MessageLogProcess, gen.MessageLogMeta, gen.MessageLogNetwork or gen.MessageLogApplication. This typing allows custom loggers to handle different sources differently, perhaps routing network logs to one destination and process logs to another.
A custom logger's type switch has to cover all five: application lines are what you see as App#<hash.'name'> in default output, and they arrive as gen.MessageLogApplication{Node, Name, Mode, Behavior}.
The default logger formats each source type distinctly in its output:
Node logs show the node name as a CRC32 hash:
Process logs show the full PID:
With IncludeName enabled, the registered name appears:
With both IncludeName and IncludeBehavior enabled, the actor type appears:
Meta process logs show the alias:
Network logs show local and remote node hashes:
These visual distinctions make scanning logs easier. At a glance, you can distinguish node events from process activity, meta process operations from network communications. The format itself tells you what layer of the system generated each message.
Beyond the message text, you can attach structured fields - key-value pairs providing context. Fields enable correlation across log entries and make logs machine-parseable.
Consider a request handler. It receives a request with an ID. Every log entry related to that request should include the ID, allowing you to filter logs to just that request's activity:
With IncludeFields enabled in the logger configuration, output shows:
Fields appear on a separate line below the message, prefixed with "fields" and aligned with the timestamp. Multiple fields are space-separated, each formatted as key:value.
In JSON output they are not promoted to the top level. They go into a nested fields object, and every value is written as a string - numbers and booleans included:
Worth knowing before writing a query against these logs: paid is "true" and not true, order_id is "12345" and not a number.
Fields only appear in output if the logger is configured to include them. The default logger requires gen.NodeOptions.Log.DefaultLogger.IncludeFields = true. Without this, fields are tracked internally but not displayed - useful if some loggers need fields while others don't.
Fields accumulate. Call AddFields multiple times and you add more fields rather than replacing existing ones. This supports incremental context building. Add session_id when the session starts. Add transaction_id when beginning a transaction. Add payment_id when processing payment. Each subsequent log includes all accumulated fields.
Remove fields with DeleteFields:
This clears the named fields from subsequent logs.
Field scoping handles nested contexts where you need temporary fields that shouldn't persist beyond a specific operation.
PushFields saves the current field set and starts a new scope. Add temporary fields, perform the operation (with those fields appearing in logs), then PopFields to restore the previous field set:
Output shows:
The operation field exists only within the push/pop scope. After popping, logs include only session_id.
Scopes can nest. Each PushFields returns the stack depth. Each PopFields returns the new depth. This supports complex nested contexts - a request containing a transaction containing multiple operations, each adding its own contextual fields that disappear when the operation completes.
One restriction protects consistency: you can't delete fields while the field stack has active frames. If you've pushed fields, pop back to the base level before deleting. This prevents deleting a field that a pending pop might restore, which would leave the field state inconsistent.
Every node starts with a default logger writing to os.Stdout. Configure it through gen.NodeOptions.Log.DefaultLogger:
TimeFormat controls timestamp display. Empty means nanoseconds since epoch. Any Go time format works - time.DateTime, time.RFC3339, or custom formats.
IncludeBehavior adds actor type names to process logs, showing which implementation generated each message.
IncludeName adds registered process names to process logs, making output more readable than PIDs alone.
IncludeFields controls whether structured fields appear in output.
EnableJSON switches to JSON format, with each message as a single-line JSON object.
To disable the default logger entirely, set Disable: true. Do this when using only custom loggers.
Custom loggers implement gen.LoggerBehavior:
The Log method receives each message. The Terminate method handles cleanup when the logger is removed or the node shuts down.
Register a logger with node.LoggerAdd:
The filter (final arguments) specifies which levels this logger handles. The logger receives only messages at those levels. Omit the filter to use gen.DefaultLogFilter, which includes all levels from Trace through Panic.
Loggers are stored per-level internally. Registering for Error and Panic stores the logger in both level maps. When an error occurs, the framework looks up the Error map and delivers the message to all loggers in that map.
Logger names must be unique. Reusing a name returns gen.ErrTaken. Remove a logger with LoggerDelete before adding a new one with the same name.
The Log method is called synchronously. If it blocks, it delays the logging path. For expensive operations - compressing logs, sending over network, database writes - make Log queue the work and return immediately, processing asynchronously.
A process can act as a logger, receiving log messages through its mailbox. This integrates logging with the actor model.
Implement the HandleLog callback in your actor:
Register the process as a logger:
Process-based logging queues messages asynchronously. The Log call places the message in the process's Log mailbox and triggers the process. The process handles log messages through HandleLog, processing them sequentially. The code that generated the log continues immediately without waiting.
This queuing prevents blocking. If the logger process is busy or the logging logic is expensive, messages queue and are processed when ready. The logging path stays fast.
One detail matters: when a logger process terminates, it's automatically removed from the logging system. No need to call LoggerDeletePID explicitly.
Two more that surprise people. Registering a process as a logger silences that process's own logging: LoggerAddPID saves its current level and sets it to gen.LogLevelDisabled, so a logger actor that also calls Log().Error(...) for its own diagnostics writes nothing. This prevents a logger from logging its way into a loop; LoggerDeletePID restores the saved level. And registering the same PID twice returns gen.ErrNotAllowed rather than replacing the first registration.
The fan-out architecture supports multiple loggers operating simultaneously with different purposes.
A typical production configuration disables the default logger and adds specialized loggers:
The colored logger handles debug through panic for console display during development. The rotate logger receives everything and writes to rotating files. Trace messages don't appear anywhere because no logger is registered for trace level.
Loggers can be added and removed dynamically. Start with console logging during development. Add file logging in staging. In production, remove console, keep files, add metrics forwarding. The system adapts without code changes.
Different processes often need different verbosity. Most processes log at Info. Increase a troublesome process to Debug temporarily. Keep infrastructure processes at Warning to reduce noise.
For processes generating high-volume logs, route them to a dedicated logger using a hidden logger. A trading engine logging every order would overwhelm general logs:
This creates isolation - the trading process logs only to .trading, and .trading receives only trading process logs. Other processes and loggers are unaffected. Without the hidden logger (using a regular logger name), the logger would also receive fan-out logs from all other processes.
Process-based loggers enable sophisticated handling. A logger process can aggregate metrics - count errors per minute, track which processes log most frequently. It can detect patterns - the same error repeating indicates a stuck condition. It can forward to external systems - send errors to Slack, metrics to Prometheus. As an actor, it maintains state, can be supervised for reliability, and integrates naturally with the rest of your system.
The framework provides three logger implementations in separate packages for common needs:
Colored (ergo.services/logger/colored) - Terminal output with ANSI colors. Highlights Ergo types (PIDs, Atoms, Refs) and colorizes log levels (yellow for warnings, red for errors, etc.). Visual clarity for development, but has performance overhead. Not suitable for high-volume production logging.
Rotate (ergo.services/logger/rotate) - File logging with automatic rotation. Rotation is time-based only: Period decides when a new file starts, floored to one minute, and there is no size trigger. Depth caps how many files are kept and Compress gzips the retired ones. Production-ready for long-running systems generating substantial logs.
Sentry (ergo.services/logger/sentry) - Forwards panics and errors to a Sentry project. Captures the panic origin stack and tags events by ergo subsystem. Centralized error tracking and alerting for production deployments. Operates alongside your console or file logger rather than replacing them.
All three integrate with the logging system through node.LoggerAdd. You can combine them - colored for console during development, rotate for persistent storage, sentry for centralized error tracking - each receiving the same filtered log stream.
For implementation details and configuration options, see , and in the extra library documentation.
This package implements the gen.Registrar interface and serves as a client library for etcd, a distributed key-value store that provides a reliable way to store data that needs to be accessed by a distributed system or cluster of machines. In addition to the primary Service Discovery function, it automatically notifies all connected nodes about cluster configuration changes and supports hierarchical configuration management with type conversion.
To create a client, use the Create function from the etcd package. The function requires a set of options etcd.Options to configure the connection and behavior.
Then, set this client in the gen.NetworkOption.Registrar options:
import (
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/registrar/etcd"
)
func main() {
var options gen.NodeOptions
...
registrarOptions := etcd.Options{
Endpoints: []string{"localhost:2379"},
Cluster: "production",
}
registrar, err := etcd.Create(registrarOptions)
if err != nil {
panic(err)
}
options.Network.Registrar = registrar
...
node, err := ergo.StartNode("demo@localhost", options)
...
}Using etcd.Options, you can specify:
Cluster - The cluster name for your node (default: "default")
Endpoints - List of etcd endpoints (default: ["localhost:2379"])
Username - Username for etcd authentication (optional)
KeepAlive is deliberately tied to LeaseTTL: detection costs two keep-alive intervals, which has to fit inside the lease for a failover to happen before the lease expires. Raising LeaseTTL without touching KeepAlive keeps that relation; setting KeepAlive by hand breaks it if you make it longer than half the TTL.
When the node starts, it will register with the etcd cluster and maintain a lease to ensure automatic cleanup if the node becomes unavailable.
The etcd registrar provides hierarchical configuration management with four priority levels:
Cross-cluster node-specific: services/ergo/config/{cluster}/{node}/{item}
Cluster node-specific: services/ergo/cluster/{cluster}/config/{node}/{item}
Cluster-wide default: services/ergo/cluster/{cluster}/config/*/{item}
The etcd registrar supports typed configuration values using string prefixes. Configuration values are stored as strings in etcd and automatically converted to the appropriate Go types when read by the registrar:
"int:123" → int64(123)
"float:3.14" → float64(3.14)
"bool:true" → bool(true)
Important: All configuration values must be stored as strings in etcd. The type conversion happens automatically when the registrar reads the configuration.
Example configuration setup using etcdctl:
Access configuration in your application:
The etcd registrar registers a gen.Event and generates messages based on changes in the etcd cluster within the specified cluster. This allows the node to stay informed of any updates or changes within the cluster, ensuring real-time event-driven communication and responsiveness to cluster configurations:
etcd.EventNodeJoined - Triggered when another node is registered in the same cluster
etcd.EventNodeLeft - Triggered when a node disconnects or its lease expires
etcd.EventApplicationLoaded - An application was loaded on a remote node
The last two are about this node's own link to etcd rather than about the cluster, and they are the pair to watch if you care whether your view of the cluster is current. There is also an etcd.EventApplicationUnloaded type declared in the package, but nothing sends it - do not write a handler that waits for it.
To receive such messages, you need to subscribe to etcd client events using the LinkEvent or MonitorEvent methods from the gen.Process interface. You can obtain the name of the registered event using the Event method from the gen.Registrar interface:
To get information about available applications in the cluster, use the ResolveApplication method from the gen.Resolver interface, which returns a list of gen.ApplicationRoute structures:
Name - The name of the application
Node - The name of the node where the application is loaded or running
Weight - The weight assigned to the application in gen.ApplicationSpec
You can access the gen.Resolver interface using the Resolver method from the gen.Registrar interface:
Get a list of all nodes in the cluster:
The etcd registrar organizes data in etcd using the following key structure:
Important Architecture Notes:
Routes (nodes/applications) use edf.Encode + base64 encoding and are stored in the routes/ subpath. Don't change anything there.
Configuration uses string encoding with type prefixes and is stored in the config/ subpath
A fully featured example can be found at in the docker directory.
This example demonstrates how to run multiple Ergo nodes using etcd as a registrar for service discovery. It showcases service discovery, actor communication, typed configuration management, and real-time configuration event monitoring across a cluster.
The etcd registrar includes comprehensive testing infrastructure:
Use the included Docker Compose setup for testing:
For debugging and manual operations:
The etcd registrar provides a robust, scalable solution for service discovery and configuration management in distributed Ergo applications, with the reliability and consistency guarantees of etcd.
Spawning processes on remote nodes
Remote spawning means starting a process on another node from your code. You call a method, provide a factory name and options, and a process starts on the remote node. From the caller's perspective, it's nearly identical to spawning locally - you get back a gen.PID and can communicate with it immediately.
This capability enables dynamic workload distribution. Your node needs to process a job but doesn't have capacity? Spawn a worker on a remote node with available resources. Your application needs to scale horizontally? Spawn processes across multiple nodes and distribute load. Remote spawning makes the cluster feel like one large computing resource rather than isolated nodes.
But remote spawning isn't automatic. Security matters. You don't want arbitrary nodes spawning arbitrary processes on your infrastructure. The framework requires explicit permission - the remote node must enable each process factory individually and can restrict which nodes are allowed to use it.
The gate is the factory registry, not the flag. EnableRemoteSpawn is on in gen.DefaultNetworkFlags, and a node that configures no flags of its own is given those defaults, so on an ordinary node the flag is already true. What actually stops a peer is that nothing is spawnable until
Controlling outgoing connections with static routing
When your code sends a message to a remote process, the framework needs to establish a connection to that node. But how does it know where the node is? By default, it asks the system (the Registrar) to look up the node's address. This works well for dynamic clusters where nodes come and go.
But sometimes you want more control. Maybe you know exactly where certain nodes are. Maybe you're behind a firewall and can't use dynamic discovery. Maybe you want to connect to external systems with fixed addresses. Static routes let you hardcode connection information directly, bypassing the discovery process entirely.
This isn't just about convenience. It's about control. When you define a static route, you're saying "I know better than the discovery system where this node is, and here's exactly how to reach it." The framework respects that - static routes are checked first, before any discovery queries.
The framework maintains an internal routing table. When you create an outgoing connection to a remote node, the framework:
Checks static routes first - Looks in the routing table for a match
The in-process harness for testing a single actor's logic
Most of what an actor does is decide. A message arrives; the actor looks at its state, perhaps asks a dependency something, and reacts - it replies, forwards, spawns a worker, logs a warning, or stops. That decision logic is the core of the actor, and it is what you most want under test: on its own, without a network, a scheduler, or the timing that makes concurrent tests flaky.
unit is built for exactly that. It spawns one behavior on a mock node, gives you a Subject to drive its callbacks by hand, and records everything the actor does so you can assert on it. The defining fact - the one everything else follows from - is that it is synchronous. There are no real goroutines and no clock: you deliver a message, the actor's handler runs to completion on the calling goroutine, and by the time the call returns the records are already there to read. Tests run in microseconds and give the same answer every time.
A unit test reads the same way every time: spawn the actor, drive an input, assert the reaction.
unit.Spawn runs the behavior's Init and returns a
Understanding the network stack for distributed communication
The network stack makes remote messaging work like local messaging. When you send to a process on another node, the framework discovers where that node is, establishes a connection if needed, encodes the message, sends it over TCP, and delivers it to the recipient's mailbox. From your perspective, it's just Send(pid, message) - whether the PID is local or remote.
This transparency requires three systems working together: service discovery to find nodes, connection management to establish reliable links, and message encoding to serialize data for transmission. Each system handles a specific problem, and together they create the illusion that remote communication is just local communication.
When you send a message to a remote process:
Routing decision - The framework examines the node portion of the PID. Local node? Direct mailbox delivery. Remote node? Continue to step 2.
What your AI agent can do with a live Ergo cluster over MCP
serves an MCP endpoint beside its web UI. Point Claude Code, Cursor or any MCP-compatible client at it, and your agent can inspect and operate the running cluster directly.
What that changes in practice: instead of opening a dashboard and deciding which panel to look at, you describe the symptom - "orders are slow since the deploy" - and the agent goes and looks. It lists the nodes, finds the processes with deep mailboxes, reads the one that looks implicated, asks it about itself, follows the message chain onto the next node, and comes back with a cause rather than a screenshot.
Three things make it worth wiring up:
One endpoint covers the whole cluster. Only the node running Observer needs it. Every other node is already inspectable, because each runs the framework's built-in system application - nothing to install, no port to open, no agent to deploy.
Boilerplate Generator for Ergo Framework Projects
The ergo tool generates the initial structure and source code for Ergo Framework projects. Instead of writing actor definitions, supervisor specs, and application boilerplate by hand, you describe what you want and the tool writes it for you.
The generated code is plain Go that you own and can modify freely. The tool understands which parts are structural wiring (regenerated as the project grows) and which parts are your business logic (never touched again).
Requires Go 1.21 or higher.
Three commands to get a running Ergo node:
The node starts immediately with an application, a supervisor and an actor, all wired together.
The tool maintains ergo.yaml in the project root. This file describes the supervision tree. Every ergo add command updates this file and regenerates the affected code.
Host address to advertise in route registration
RoutePort
Port to advertise in route registration (0 = use actual listening port)
Number of shard actors the keyspace is split across. All nodes in a domain must use the same value - a peer with a different shard count is rejected during the handshake.
Separator
"/"
The hierarchy separator used by MonitorPrefix. A prefix matches the key itself and everything below it at a separator boundary.
Peers
none
Static seed nodes to contact for discovery when no registrar is available. Grid keeps trying to reach them.
all shards
MonitorAll
every key in the domain
all shards
the owner lost a last-writer-wins conflict
ReasonNodeDown
the owner's node left the cluster
Live diagnostics. Expose the running system to any AI assistant through the MCP surface of the Observer application.
Observer for configuring and bounding that surface
Examples for working reference projects
Password - Password for etcd authentication (optional)
TLS - TLS configuration for secure connections (optional)
InsecureSkipVerify - Option to ignore TLS certificate verification
DialTimeout - Connection timeout (default: 10s)
RequestTimeout - Request timeout (default: 10s)
KeepAlive - gRPC keep-alive interval. Left at zero it is derived from LeaseTTL as LeaseTTL/3, floored at one second - so 3.33s with the default TTL, not 10s
LeaseTTL - Lease lifetime in seconds (default: 10). The node's registration disappears this long after it stops renewing
SuspectGrace - How long a node that stopped renewing is kept as suspect before removal (default: 30s)
SweepInterval - How often expired registrations are swept (default: 1s)
Global default: services/ergo/config/global/{item}
"bool:false"bool(false)"hello" → "hello" (strings without prefixes remain unchanged)
etcd.EventApplicationStarted - Triggered when an application starts on a remote node
etcd.EventApplicationStopping - Triggered when an application begins stopping on a remote node
etcd.EventApplicationStopped - Triggered when an application is stopped on a remote node
etcd.EventConfigUpdate - The cluster configuration was updated
etcd.EventRegistrarConnected - This node's session with etcd is established, carrying Info. Also fired after a reconnect, so it is how you learn that discovery is working again
etcd.EventRegistrarDisconnected - The session was lost, carrying Reason. Existing node-to-node connections keep working; what stops is discovery of anything new
Mode - The application's startup mode (gen.ApplicationModeTemporary, gen.ApplicationModePermanent, gen.ApplicationModeTransient)
Tags - The labels assigned to this instance, for selecting between deployments (blue/green, canary, maintenance)
State - The current state of the application (gen.ApplicationStateLoaded, gen.ApplicationStateInitializing, gen.ApplicationStateRunning, gen.ApplicationStateStopping)
Version - The version from the application's gen.ApplicationSpec, so instances running different releases are distinguishable
network.EnableSpawn(name, factory)Set the flag when you want the transport-level door shut - a node that must never accept a remote spawn, whatever it has registered. Note that it is only consulted when Flags.Enable is true, and that supplying Flags yourself replaces the whole default set rather than adjusting one member of it:
This flag is a global switch. With it disabled, all remote spawn requests fail immediately with gen.ErrNotAllowed. With it enabled, requests proceed to the next level of security: per-factory permission.
Even with EnableRemoteSpawn turned on, remote nodes can't spawn anything until you explicitly enable specific process factories:
Now remote nodes can request spawning using the factory name "worker". The factory function createWorker returns a gen.ProcessBehavior, just like local spawning. When a remote spawn request arrives with name "worker", the framework calls createWorker() to instantiate the process.
The factory name is the permission token. Remote nodes must use this exact name when requesting spawns. If they request "worker" and you haven't enabled it, the request fails. If they request "admin_process" without permission, it fails. You control the namespace of what's spawnable.
By default, EnableSpawn allows all nodes to use the factory. But you can restrict it to specific nodes:
Now only those two nodes can spawn workers. Requests from other nodes fail with gen.ErrNotAllowed.
You can update the access list dynamically:
Calling EnableSpawn again with the same factory name updates the access list. The factory must be the same (same type) - you can't change which factory is associated with a name after the first EnableSpawn call. Attempting to do so returns an error.
To remove nodes from the access list:
This removes scheduler@node2 from the allowed list. Other nodes in the list remain allowed.
To completely disable a factory:
Without any node arguments, DisableSpawn removes the factory entirely. All future spawn requests for that name fail.
To re-enable the factory with an open access list (any node can spawn):
This is the explicit "allow all nodes" configuration.
To spawn a process on a remote node, first get a gen.RemoteNode interface:
GetNode establishes a connection if needed. If a connection already exists, it returns immediately. If discovery or connection fails, you get an error.
With the remote node handle, spawn a process:
The gen.ProcessOptions are the same as local spawning: mailbox size, compression settings, parent process options. The remote node respects these options when creating the process.
You can pass initialization arguments to the remote process:
These arguments are passed to the factory's Init callback, just like local spawning. The arguments must be serializable via EDF - primitives, registered structs, framework types. Complex arguments require type registration on both sides.
To spawn and register the process with a name:
The first argument is the registration name. The remote process is registered under that name on the remote node, allowing other processes on that node (or other nodes) to find it via gen.ProcessID{Name: "worker-001", Node: "worker@otherhost"}.
The gen.Process interface provides methods for remote spawning from within a process:
This differs from using RemoteNode.Spawn in a subtle but important way: the spawned process inherits properties from the calling process, not from the node.
Inherited properties:
Application name - if the caller is part of an application, the remote process becomes part of that application too
Logging level - the remote process uses the same log level as the caller
Environment variables - if ExposeEnvRemoteSpawn security flag is enabled, the remote process gets a copy of the caller's environment
This inheritance enables application-level distribution. If your application spawns processes remotely using process.RemoteSpawn, those processes belong to your application's supervision tree (conceptually), inherit your configuration, and operate as extensions of your application rather than independent processes.
The same inheritance applies.
Remote spawn behavior differs based on whether you spawn from a process or from the node:
From a process (process.RemoteSpawn):
The spawned process inherits attributes from the calling process:
Parent PID: Set to the calling process's PID
Group Leader: Set to the calling process's group leader
Application: Set to the calling process's application name (if caller belongs to an application)
Log Level: Inherits the calling process's log level
Environment: Inherits the calling process's environment (if SecurityOptions.ExposeEnvRemoteSpawn is enabled)
The remote process can send messages to its parent using process.Parent(). If LinkChild: true is set in options, the link is established after spawn. However, the parent is on a different node - if the network connection drops, the remote process receives an exit signal for the lost parent and may terminate if linked.
From the node (RemoteNode.Spawn):
The spawned process receives attributes from the requesting node's core:
Parent PID: Set to the requesting node's core PID
Group Leader: Set to the requesting node's core PID
Application: Not set (empty - process doesn't belong to any application)
Log Level: Inherits the requesting node's default log level
Environment: Inherits the requesting node's environment (if SecurityOptions.ExposeEnvRemoteSpawn is enabled)
This creates independent processes without application affiliation. Use this for standalone remote workers that don't need to be part of an application's logical structure.
An actor with SetTrapExit(true) refuses exit requests. They arrive as regular messages and it decides what to do with them. Its parent is the exception: an exit from the parent always terminates the actor, whatever reason it carries, trap or no trap.
process.RemoteSpawn makes the caller that parent. The caller therefore keeps a stop signal the remote process cannot decline:
A coordinator that places work on other nodes needs exactly this. It can reclaim what it started without the remote side agreeing to stop.
Three limits come with it.
Only the spawner gets it. Process options have no parent field, so you cannot point this at a third process. Leader sets the group leader, which shapes environment inheritance and logging and grants nothing here. For a supervisor on the target node to hold the stop, that supervisor has to do the spawn.
Node-level spawn does not grant it. RemoteNode.Spawn records the requesting node's core as the parent, and no process of yours is that core. Stopping such a process means asking it with a message.
It dies with the PID that holds it. A coordinator that restarts comes back with a new PID. It is no longer the parent of what it spawned earlier, and those processes keep running out of its reach.
The last limit shapes the design. Give the remote process a cooperative stop as well, a message it honours by terminating itself, so a restarted coordinator can still ask. And when the stop has to survive that restart, spawn on the target node under a local supervisor: parent and children live on the same node, a supervisor restart takes its children with it instead of orphaning them, and the coordinator asks the supervisor to stop a child rather than owning the relationship itself.
A process built directly on gen.ProcessBehavior runs its own message loop, so this rule is its own to implement or ignore.
By default, remote processes don't inherit environment variables. This is a security decision - you probably don't want to expose your node's configuration to remote processes.
To enable environment inheritance:
Now when you use process.RemoteSpawn, the remote process receives a copy of the calling process's environment. The remote node reads these values and sets them on the spawned process.
Important: Environment variable values must be EDF-serializable. Strings, numbers, booleans work fine. Custom types require registration via node.Network().RegisterType (see Network Transparency for details on the type registry; the legacy edf.RegisterTypeOf still works but is deprecated). If an environment variable contains a non-serializable value (e.g., a channel, function, or unregistered struct), the remote spawn fails entirely with an error like "no encoder for type <type>". The framework doesn't skip problematic variables: any non-serializable value causes the entire spawn request to fail.
Environment inheritance only works with process.RemoteSpawn. Using RemoteNode.Spawn doesn't inherit environment because there's no calling process - it's a node-level operation.
When you call remote.Spawn:
Check capabilities - The local node checks if the remote node's EnableRemoteSpawn flag is true (learned during handshake). If false, fail immediately.
Create spawn message - Package the factory name, process options, and arguments into a MessageSpawn protocol message. Include a reference for tracking the response.
Send request - Encode and send the message to the remote node. Wait for a response (this is synchronous - remote spawning blocks until the remote node replies).
Remote processing - The remote node receives the message, checks if the factory is enabled, checks if the requesting node is allowed, calls the factory function, spawns the process with the given options.
Response - The remote node sends back a MessageResult containing either the spawned PID or an error. The local node receives this, resolves the waiting request, and returns the PID to the caller.
If anything fails (factory not found, access denied, remote node terminating, initialization timeout), the error is returned to the caller. The entire operation is synchronous from the caller's perspective - you call Spawn and block until the process is created or an error occurs.
Performance - Remote spawning is slower than local spawning. There's network latency, message encoding, and a synchronous request-response roundtrip. If you're spawning hundreds of processes, doing it remotely will be noticeably slower. Consider spawning a pool locally and distributing work via messages rather than spawning on-demand remotely.
Timeouts - Remote spawn has a maximum InitTimeout of 15 seconds (3x DefaultRequestTimeout). If the remote process's ProcessInit takes longer, spawn fails with gen.ErrTimeout. Setting InitTimeout higher than 15 seconds returns gen.ErrNotAllowed immediately without attempting the spawn.
Failure modes - Remote spawn can fail in ways local spawn can't. The network connection can drop mid-request. The remote node can crash before responding. The factory might exist but lack permission. Handle errors explicitly and have fallback strategies (retry, spawn locally, defer the work).
Resource ownership - A process spawned on a remote node runs on that node's resources (CPU, memory). It's part of that node's process table. If the remote node terminates, the process dies. If you're distributing workload, be aware of which node owns which processes.
Linking - Both LinkChild and LinkParent options work for remote spawn. The link is established after the remote process is created. If the network connection drops, linked processes receive exit signals for the lost peer.
Application membership - Processes spawned via RemoteNode.Spawn don't belong to any application. Processes spawned via process.RemoteSpawn inherit the caller's application. This affects supervision, lifecycle, and monitoring.
Registration names - Use SpawnRegister carefully. The name you provide is registered on the remote node. If that name is already taken, spawn fails. Ensure your naming strategy avoids conflicts, especially if multiple nodes are spawning on the same target.
Dynamic scaling - Your application detects high load and spawns additional workers on remote nodes to handle the burst. When load decreases, workers terminate naturally and resources are freed.
Specialized hardware - Some nodes have GPUs, fast storage, or special network access. Spawn processes on those nodes when you need their capabilities, rather than sending data back and forth.
Fault isolation - Spawn risky operations on remote nodes. If they crash or consume excessive resources, they don't affect your local node's stability.
Data locality - If data lives on a specific node (in memory, on local disk), spawn processing near the data rather than transferring it across the network.
Heterogeneous clusters - Different nodes run different process types. Scheduler nodes spawn job processors on worker nodes. API nodes spawn request handlers on computation nodes. Remote spawning enables this separation.
Remote spawning isn't always the right answer. For static topologies where processes have fixed homes, use supervision trees and let supervisors spawn locally. For message-passing workloads where spawning overhead matters, use process pools and distribute work via messages. Remote spawning shines when you need dynamic, on-demand process creation across a cluster.
For understanding the underlying network mechanics, see Network Stack. For controlling connections to remote nodes, see Static Routes.
Falls back to discovery - If no static route exists, queries the Registrar
Tries proxy routes - If direct connection fails, attempts proxy routes
This order is important, and step 2 is reached only when step 1 found nothing. A matching static route is not a preference, it is the whole answer: every matching route is tried by weight, and if they all fail the attempt ends with gen.ErrNoRoute. The registrar is never asked as a second chance. Defining a route for "prod-*" therefore takes prod-db@example.com out of discovery permanently - you have taken control, including of the failure.
The routing table uses pattern matching. When the framework needs to connect to prod-db@example.com, it checks all static routes against that name using Go's regexp.MatchString. Any routes whose patterns match become candidates. If multiple routes match, they're sorted by weight (higher weights first), and the framework tries them in order until one succeeds.
To add a static route, use AddRoute from the network interface:
This tells the framework: "When connecting to prod-db@example.com, use host 10.0.1.50 on port 4370 with TLS enabled. This route has weight 100."
The match pattern is a regular expression. Exact names like "prod-db@example.com" match only that node. Patterns like "prod-.*" match multiple nodes - prod-db@example.com, prod-api@example.com, prod-cache@example.com. Use anchors (^ and $) for precise matching: "^prod-db@example.com$" matches exactly that name and nothing else.
The weight determines priority when multiple routes match the same node. Higher numbers mean higher priority. If you have two routes for "prod-.*" - one with weight 100 (the default datacenter) and one with weight 200 (a faster backup datacenter) - the framework tries weight 200 first.
When the framework looks up prod-db2@example.com, it finds all matching routes: the prefix match (prod-.*), the suffix match (.*@example.com), and the complex pattern (^prod-db[0-9]+@example.com$). It sorts them by weight and tries the highest-weight route first.
The gen.NetworkRoute struct gives you fine-grained control over how connections are established:
The simplest route specifies connection parameters directly:
When the framework uses this route, it connects to the specified host and port with TLS. The handshake and protocol versions default to the node's configured versions if you don't specify them explicitly.
You can combine static patterns with dynamic resolution:
The pattern selects which nodes are resolved this way. It does not blend the two sources: once the resolver answers, the framework builds a fresh route out of what came back, and any Route fields configured beside the resolver contribute nothing to it. Host, port, TLS and the flags all come from the resolver. Only two things fall back to the node's own configuration when the resolver leaves them empty: the certificate manager, and the cookie.
So a TLS: true sitting next to a Resolver does not force TLS onto a resolver answer that says otherwise, and a Host there does not redirect the connection. Use this form to point a subset of nodes at a different discovery service. To dictate the address yourself, give the route a Route and no Resolver - a plain static route is the form that is honoured verbatim.
Each route can override the node's default authentication cookie:
This is essential when connecting to nodes outside your cluster. Your internal nodes use one cookie (say, "internal-cluster-secret"). An external partner's nodes use a different cookie (say, "shared-secret-with-partner"). Without per-route cookies, you'd have to use the same cookie everywhere or give up on connecting to external systems.
For TLS connections, you can specify a custom certificate manager:
Different routes can use different certificates. Your production nodes might use certificates from one CA. A partner's nodes might use certificates from another CA. Each route gets its own certificate manager, allowing you to maintain separate trust chains.
Certificate validation, on the other hand, is not per route. gen.NetworkRoute carries an InsecureSkipVerify field, but every outgoing path overwrites it with the node-wide NetworkOptions.InsecureSkipVerify before dialling, so setting it on a route neither loosens nor tightens anything. One route cannot be strict while another is lax: the node decides, and the setting to reach for is NetworkOptions.InsecureSkipVerify. Incoming connections are the exception - there AcceptorOptions.InsecureSkipVerify is honoured per acceptor.
You can override network capabilities for specific routes:
This is about defense. When you connect to an external node, you probably don't want them spawning arbitrary processes on your node or starting applications remotely. Custom flags let you expose only the features you're comfortable with for that specific connection.
Some advanced scenarios require translating atom values during communication:
When sending to this route, the framework automatically replaces mynode@localhost with legacy_node in all messages. On receiving, it reverses the mapping. This is rarely needed - most systems agree on naming conventions. But when integrating with legacy systems or systems with incompatible naming schemes, atom mapping saves you from rewriting every piece of code that references those atoms.
You can set the logging level for a specific connection:
Normally your network stack runs at INFO or WARNING level. But when debugging a specific connection, you want TRACE logs for that connection without drowning in logs from all other connections. Per-route logging gives you surgical debugging.
The framework tries routes in weight order when multiple patterns match the same node:
When connecting to prod-db@example.com, both patterns match. The framework sorts them by weight and tries weight-200 first. If that connection fails (host unreachable, handshake failure, timeout), it tries weight-100. This gives you automatic failover.
Important limitation: You can't add the same pattern twice. AddRoute returns gen.ErrTaken if the pattern already exists - the pattern is the routing table key. To achieve multi-route failover for a single node, you need different patterns that both match:
Both patterns match prod-db@example.com, but they're different strings, so both can be added to the routing table.
Alternatively, use a resolver-based route. The resolver can return multiple addresses, and the framework tries them in order, letting the resolver handle failover logic.
To see if a route exists for a node:
This queries the routing table without establishing a connection. You get back all routes whose patterns match the node name, sorted by weight. The highest-weight route is first - that's the one the framework would try first when actually connecting.
To remove a static route:
The pattern you pass to RemoveRoute must exactly match the pattern you used in AddRoute. It's not a regex match - it's a literal string key lookup in the routing table. If you added "prod-.*", you must remove "prod-.*" exactly.
Removing a route doesn't affect existing connections. If you have an active connection to prod-db@example.com and you remove its static route, the connection stays alive. Removing a route only affects future connection attempts. The next time the framework needs to connect to that node, it won't find the static route and will fall back to discovery.
Sometimes you can't connect directly to a node. Maybe it's behind a firewall. Maybe it's in a private network. Proxy routes are meant to let you connect through an intermediate node:
The intent is that connecting to backend-db@internal.local opens a connection to gateway@dmz.example.com first and asks the gateway to forward. Today the attempt stops at the gateway step with gen.ErrUnsupported.
Proxy routes have the same pattern matching and weight semantics as direct routes. You can define multiple proxy routes for the same pattern with different weights for failover.
Those five are the whole of gen.NetworkProxyFlags. There is no EnableLink or EnableMonitor field, and EnableSpawn is a method on gen.Network, not a flag.
MaxHop is stored and reported but not yet acted on: nothing decrements it, and there is no DefaultProxyMaxHop constant. It is part of the same unimplemented feature as the rest of this section.
Static routes are checked first, always. When the framework needs to connect to a node:
Check routing table - Pattern match against static routes
Try static routes - Attempt connection using matched routes (by weight order). If any matched, this is the last step: on failure the answer is gen.ErrNoRoute
Query discovery - Only when no static route matched, ask the Registrar
Try discovered routes - Attempt connection using discovered addresses
Try proxy discovery - If direct connection fails, try discovered proxy routes (which currently end in gen.ErrUnsupported, see the warning above)
Fail - Return gen.ErrNoRoute
A static route is not a preference the framework may reconsider. If you have one for prod-db pointing to 10.0.1.50 and that address is down, the connection fails - the Registrar is never asked, even though it might know a working address. This is by design: you took control, and that includes the failure. Remove or narrow the route to hand the node back to discovery.
But combining them is powerful. You can define static routes with resolvers:
Now all production nodes use the static route for pattern matching, but the resolver for address lookup. You get the control of static routes (selecting which nodes use this configuration) with the dynamism of discovery (nodes can move without updating your code).
Fixed infrastructure - If your nodes run on specific servers with static IPs, static routes are simpler than running a discovery service. Add routes for your database, cache, and API servers, and you're done.
Firewall restrictions - When discovery protocols can't traverse your firewall, static routes work around it. The internal nodes discover each other normally. External access uses static routes pointing to your gateway.
External integration - Connecting to nodes outside your cluster almost always requires static routes. You don't control their discovery system (if they even have one). You just need to reach specific addresses.
Testing - Hardcoding routes during development lets you point at local test nodes without configuring a full discovery system.
Performance - Static routes eliminate discovery latency. The framework connects immediately without the resolver round-trip. For frequently accessed nodes, this shaves milliseconds off connection establishment.
Security boundaries - Different routes can use different cookies and certificates. When integrating multiple trust domains, static routes let you configure each boundary explicitly.
Static routes aren't a replacement for discovery. They're a tool for cases where discovery doesn't fit. Most production clusters use discovery for internal nodes (dynamic, automatic) and static routes for fixed external connections (explicit, controlled). The framework supports both, and they work together.
For details on how connections are established, see Network Stack. For understanding the discovery system that static routes bypass, see Service Discovery.
Proxying is not implemented yet. The API below exists and a proxy route can be registered and read back, but no connection is ever made through it: every proxy path ends in connectProxy, which logs "proxy feature is not implemented yet" and returns gen.ErrUnsupported. A node that can only be reached through a gateway cannot be reached at all today. This section describes the shape the feature will take; treat it as a preview, not as something to build on.
Connection lookup - Check if a connection to that node already exists. If yes, use it. If no, continue to step 3.
Discovery - Query the registrar (or check static routes) to find where the remote node is listening: hostname, port, TLS requirements, protocol versions.
Connection establishment - Open TCP connections to the remote node, perform mutual authentication via handshake, negotiate capabilities, exchange caching dictionaries, create a connection pool.
Message transmission - Encode the message into bytes (EDF), optionally compress it, wrap it in a protocol frame (ENP), send it over one of the TCP connections in the pool.
Remote delivery - The receiving node reads the frame, decompresses if needed, decodes back to Go values, routes to the recipient's mailbox.
This entire pipeline is invisible to your code. You call Send, and the framework does the rest.
Before connecting to a remote node, the framework needs to know where that node is. Service discovery translates logical node names (worker@example.com) into connection parameters (IP, port, TLS, protocol versions).
The embedded registrar provides basic discovery:
One node per host runs a registrar server (whoever started first)
Other nodes connect as clients
Same-host discovery is direct (no network)
Cross-host discovery uses UDP queries
Automatic failover if the server node dies
For production clusters, external registrars provide more features:
etcd - Centralized discovery, application routing, configuration storage; registration held by a lease, changes delivered by a prefix watch
Saturn - Purpose-built for Ergo, immediate event propagation, efficient at scale
The embedded registrar works for development and small deployments. For larger clusters or dynamic topologies, use etcd or Saturn. The choice is transparent to your code - you specify the registrar at node startup, and everything else works identically.
For details, see Service Discovery.
Discovery is dynamic - nodes register themselves, and others query to find them. But sometimes you want explicit control. Maybe nodes have fixed addresses. Maybe you're behind a firewall that blocks discovery. Maybe you're connecting to external systems.
Static routes let you hardcode connection parameters:
Now when connecting to prod-db@example.com, the framework uses your route directly. No discovery query. No registrar involvement. You've taken control.
Static routes support pattern matching ("prod-.*"), multiple routes with failover weights, and hybrid approaches (use patterns for selection, resolvers for address lookup). You can configure per-route cookies, certificates, network flags, and atom mappings.
The framework checks static routes first, always. A matching static route takes that node out of discovery entirely: every matching route is tried, and when they all fail the attempt ends with gen.ErrNoRoute - the registrar is never consulted. Discovery is reached only when no static route matched the name.
For details, see Static Routes.
Once the framework knows where to connect (from discovery or static routes), it establishes a connection pool.
The handshake performs mutual authentication using challenge-response. Node A connects to node B:
A sends hello with random salt and digest (computed from salt + cookie)
B verifies digest - if cookies match, digest is correct
B sends its own challenge
A verifies B's response
Both sides authenticated
If TLS is enabled, certificate fingerprints are exchanged and verified too.
After authentication, nodes exchange introduction messages:
Node names and version information
Network flags (capabilities: remote spawn? important delivery? fragmentation?)
Caching dictionaries (atoms, types, errors that will be used frequently)
The flags negotiation ensures nodes with different feature sets can work together. Features not supported by both sides are disabled for that connection.
The caching dictionaries enable efficiency. Instead of encoding "mynode@localhost" repeatedly (19 bytes), it gets a cache ID and subsequent uses encode as 2 bytes.
After handshake, the accepting node tells the dialing node to create a connection pool:
Pool size (default 3 TCP connections)
Acceptor addresses to connect to
The dialing node opens additional TCP connections using a shortened join handshake (skips full authentication since the first connection already authenticated). These connections join the pool, forming a single logical connection with multiple physical TCP links.
Multiple connections enable parallel message delivery. Each message goes to a connection based on the sender's identity, and the receiving side creates multiple receive queues per TCP connection for concurrent processing. This two-level mechanism (sender-side link selection and receiver-side queue routing) preserves per-sender message ordering while enabling parallelism across different senders. For details on how ordering works, including the KeepNetworkOrder flag and when to disable it, see Message Ordering.
Introduced in v3.3.0.
TCP keepalive operates at the OS level - it detects hard network failures like unplugged cables or crashed hosts. But it can't detect application-level problems: a stuck process that stopped reading from a connection, a flusher that failed silently, a goroutine that never got scheduled. The connection looks alive to TCP while no useful data flows.
Software keepalive works at the protocol level. When a connection pool item has nothing to send, its flusher periodically writes a small keepalive packet. The receiving side expects these packets and sets a read deadline based on the sender's advertised period. If nothing arrives - no real messages and no keepalive packets - the deadline fires and the connection is terminated.
Each side advertises its keepalive period during handshake. This allows asymmetric configuration: a node in a reliable datacenter might send keepalive every 15 seconds, while a node on an unstable network might send every 5 seconds. The receiver calculates its deadline from the sender's period, not its own.
The timeout calculation uses the remote node's period, not the local one. If the remote node advertises a 15-second period and you configure 3 misses, the connection is considered dead after 45 seconds of silence. Real messages reset the deadline just like keepalive packets do - on a busy connection, keepalive is never sent because regular traffic keeps the deadline from expiring.
When a keepalive timeout fires on any pool item, the entire connection is terminated - not just the affected TCP link. A single unresponsive link is strong evidence that the whole network path to the remote node is down. This triggers the standard cleanup flow: monitors receive MessageDown, links receive MessageExit, and the connection is removed from the node's connection map.
Software keepalive is enabled by default (15-second period, 3 misses, 45-second timeout). Set EnableSoftwareKeepAlive to 0 to disable it. Acceptors and routes can override the misses count; zero inherits from NetworkOptions.
Both sides must have keepalive enabled for the feature to activate. If either side advertises period 0, the connection falls back to TCP-only keepalive with infinite read deadline - neither side sends keepalive packets and neither side sets read deadlines. This means a single node with keepalive disabled in a cluster removes protection for all its connections, not just its own. During a rolling upgrade from older nodes (which don't support the feature) to newer ones, connections between old and new nodes will not have software keepalive until both sides are upgraded.
Once a connection exists, messages flow through encoding and framing.
EDF is a binary encoding specifically designed for the framework's communication patterns. It's type-aware - each value is prefixed with a type tag (e.g., 0x95 for int64, 0xaa for PID, 0x9d for slice). The decoder reads the tag and knows what follows.
Framework types like gen.PID and gen.Ref have optimized encodings. Structs are encoded field-by-field in declaration order (no field names on the wire). Custom types must be registered on both sides via node.Network().RegisterType (typically from an application's Load callback). During handshake, nodes exchange their type lists to agree on encoding.
Compression is opt-in, per process, and off until you ask for it. There is no node-level compression option: the switch is ProcessOptions.Compression at spawn, or SetCompression at runtime, and only Enable: true makes the wire path consider it. Once enabled, a message larger than the threshold (default 1024 bytes) is compressed with GZIP, ZLIB or LZW; the protocol frame says so, and the receiver decompresses before decoding. A process that never enables it sends everything uncompressed however large the message is.
For details on EDF - type tags, struct encoding, registration requirements, compression, caching - see Network Transparency.
ENP wraps encoded messages in frames for transmission. Each frame has an 8-byte header with magic byte, protocol version, frame length, order byte, and message type. The frame body contains sender/recipient identifiers and the EDF-encoded payload.
The order byte preserves message ordering per sender. Messages from the same sender have the same order value and route to the same receive queue, guaranteeing sequential processing. Messages from different senders have different order values and route to different queues, enabling parallel processing.
For details on protocol framing, order bytes, receive queue distribution, and the exact byte layout, see Network Transparency.
Introduced in v3.3.0.
When a message exceeds the fragment size threshold (default 65000 bytes), the framework splits it into smaller pieces for transmission and reassembles them on the receiving side. This happens after compression; if a compressed message is still too large, it gets fragmented. From your code's perspective, nothing changes. You send a large message, and it arrives intact.
Fragmentation works with all message types: regular sends, important delivery, calls, and events. It composes with compression: a message can be compressed first, then fragmented, and on the receiving side defragmented and then decompressed.
When KeepNetworkOrder is disabled for a process, the framework distributes fragments across all TCP connections in the pool, using the full bandwidth of the connection. This is useful for transferring large payloads where throughput matters more than ordering. When KeepNetworkOrder is enabled (the default), all fragments travel through a single TCP connection to preserve message ordering for that sender.
Both nodes must have EnableFragmentation in their network flags. If either side doesn't support it, large messages are sent as-is (subject to MaxMessageSize limits). During handshake, nodes exchange their fragmentation capability, and the feature activates only when both sides agree.
MaxMessageSize is a logical limit on the EDF-encoded message, checked before compression and fragmentation. On the receiving side, the framework tracks the accumulated size of received fragments and rejects the assembly if it exceeds the limit.
FragmentSize controls at what point messages get split. This is a sender-side setting; the receiver reassembles whatever arrives regardless of the sender's fragment size. Two nodes can use different fragment sizes.
FragmentTimeout sets how long the receiver waits for all fragments before discarding an incomplete assembly. If a sender crashes mid-message or a connection drops, partial assemblies are cleaned up after this timeout.
MaxFragmentAssemblies limits how many messages can be simultaneously reassembled per connection, protecting against memory exhaustion from many concurrent large messages.
Network transparency means remote operations look like local operations. You send to a PID without checking if it's local or remote. You establish links and monitors the same way regardless of location. The framework handles discovery, encoding, and transmission automatically.
But transparency has limits:
Latency - Remote sends take milliseconds vs microseconds for local
Bandwidth - Network links have finite capacity, local operations don't
Failures - Networks fail in ways local memory doesn't (packets lost, connections drop, nodes unreachable)
Partial failures - Some nodes work while others fail (local systems fail entirely or work entirely)
The framework makes distributed programming feel local, but you still need to design for network realities: use timeouts, handle connection failures, prefer async over sync, batch messages, keep payloads small.
For deep understanding of how transparency works - EDF encoding, struct serialization, type registration, important delivery, failure semantics - see Network Transparency.
Configure the network stack in gen.NodeOptions.Network:
Mode - NetworkModeEnabled enables full networking with acceptors. NetworkModeHidden allows outgoing connections only (no acceptors). NetworkModeDisabled disables networking entirely.
Cookie - Shared secret for authentication. All nodes must use the same cookie to communicate. Set explicitly for distributed deployments.
MaxMessageSize - Maximum incoming message size. Protects against memory exhaustion. Default unlimited (fine for trusted clusters).
Flags - Control capabilities. Remote nodes learn your flags during handshake and can only use features you've enabled. EnableRemoteSpawn allows spawning (with explicit permission per process). EnableImportantDelivery enables delivery confirmation. EnableFragmentation enables message fragmentation for large messages (both sides must enable). EnableSoftwareKeepAlive sets the keepalive period in seconds (see Software Keepalive).
The defaults are all-or-nothing, and this catches people. gen.DefaultNetworkFlags is substituted only while Flags.Enable is false - the moment you write Flags: gen.NetworkFlags{Enable: true, ...}, your literal stands exactly as written and every field you did not name is false. So enabling one flag silently turns off fragmentation, important delivery, tracing, clock skew, proxy accept, simultaneous connect, wrapped errors and the 15-second software keepalive. To change one thing, start from the defaults:
Acceptors - Define listeners for incoming connections. Multiple acceptors on different ports are supported. Each can have its own cookie, TLS, and protocol.
The framework provides four extension points:
gen.NetworkHandshake - Control connection establishment and authentication. Implement this to change how nodes authenticate or how connection pools are created.
gen.NetworkProto - Control message encoding and transmission. The Erlang distribution protocol is implemented as a custom proto, allowing Ergo nodes to join Erlang clusters.
gen.Connection - The actual connection handling. Implement this for custom framing, routing, or error handling.
gen.TypeRegistry - Optional capability that proto implementations may declare to expose a wire-format type registry. The default ENP/EDF stack implements it. The Erlang distribution proto does not, since the Erlang external term format is schemaless on the wire. When a node has multiple protos configured, node.Network().RegisterType distributes registration to every TypeRegistry-capable proto strictly: any per-proto failure fails the call. Protos that do not implement TypeRegistry are skipped silently.
You can register multiple handshakes and protos, allowing one node to support multiple protocol stacks simultaneously:
This enables migration scenarios (gradually migrate from Erlang to Ergo) and integration scenarios (connect to systems using different protocols).
Once connections exist, you can spawn processes and start applications on remote nodes:
Remote spawning requires the remote node to explicitly enable it:
Without explicit permission, remote spawn requests fail. This prevents arbitrary code execution.
The same pattern applies to starting applications:
Requires:
This security model ensures you control exactly what remote nodes can do on your node.
This chapter provided an overview of how the network stack operates. For deeper understanding:
Service Discovery - How nodes find each other, application routing, configuration management, embedded vs external registrars
Network Transparency - How messages are encoded, EDF details, protocol framing, compression, caching, important delivery
Static Routes - Explicit routing configuration, pattern matching, failover, proxy routes
Each of these chapters dives deep into its specific topic, giving you the details needed for production deployments.
type ResearchAgent struct {
act.Actor
notes []string
}
type MessageResearchTask struct {
Query string
ReplyTo gen.PID
}
func (a *ResearchAgent) HandleMessage(from gen.PID, msg any) error {
switch m := msg.(type) {
case MessageResearchTask:
result := callLLM(m.Query) // blocking call, isolated per agent
a.notes = append(a.notes, result)
a.Send(m.ReplyTo, result)
}
return nil
}
func factory_ResearchAgent() gen.ProcessBehavior { return &ResearchAgent{} }type AgentPool struct {
act.Pool
}
func (p *AgentPool) Init(args ...any) (act.PoolOptions, error) {
return act.PoolOptions{
PoolSize: 10,
WorkerFactory: factory_ResearchAgent,
}, nil
}
func factory_AgentPool() gen.ProcessBehavior { return &AgentPool{} }
// Spawn the pool
poolPID, _ := node.Spawn(factory_AgentPool, gen.ProcessOptions{})
// Send tasks. The pool forwards to an available worker automatically.
node.Send(poolPID, MessageResearchTask{Query: "Summarize Q3 report"})type AgentRouter struct {
act.Router
}
func (r *AgentRouter) Init(args ...any) (act.RouterOptions, error) {
return act.RouterOptions{
Routes: []act.Route{
{Name: "research", Factory: factory_ResearchAgent},
{Name: "analysis", Factory: factory_AnalysisAgent},
{Name: "codegen", Factory: factory_CodeAgent},
{Name: "summary", Factory: factory_SummaryAgent},
},
}, nil
}
func (r *AgentRouter) RouteMessage(from gen.PID, msg any) gen.Atom {
switch msg.(type) {
case MessageResearchTask: return "research"
case MessageAnalyzeTask: return "analysis"
case MessageCodeTask: return "codegen"
case MessageSummaryTask: return "summary"
}
return act.RouteDiscard
}// ResearchAgent forwards its result to AnalysisAgent
func (a *ResearchAgent) HandleMessage(from gen.PID, msg any) error {
switch m := msg.(type) {
case MessageResearchTask:
findings := a.research(m.Query)
a.Send(a.analysisPID, MessageAnalyze{Findings: findings, ReplyTo: m.ReplyTo})
}
return nil
}// Register the factory on the target node. Security: only named factories
// can be spawned remotely.
network.EnableSpawn("research-agent", factory_ResearchAgent)
// From any other node, get a handle and spawn
remote, _ := node.Network().GetNode("worker@otherhost")
pid, _ := remote.Spawn("research-agent", gen.ProcessOptions{})
// Send works identically whether pid is local or remote
node.Send(pid, MessageResearchTask{Query: "..."})// Producer: research agent publishes findings
token, _ := producer.RegisterEvent("research.findings", gen.EventOptions{})
producer.SendEvent("research.findings", token, Finding{Topic: "market-trends"})
// Subscribers on any nodes
process.MonitorEvent(gen.Event{Name: "research.findings", Node: "research@host"})import "ergo.services/application/observer"
node, _ := ergo.StartNode("mynode@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
observer.CreateApp(observer.Options{Port: 9911}),
},
})claude mcp add --transport http ergo http://localhost:9911/mcpYou: "Why is the order processing agent slow?"
AI: reading ergo://cluster ... three orders@* nodes, one restarted 4m ago
cluster_query processes over the three, minMailbox=100
-> orders-2: order_processor holds 847 messages (the others hold single digits)
reading ergo://orders-2@host/process/<pid>
-> running for 30s, mailbox latency unavailable (built without -tags=latency)
process_state on that pid
-> the actor's own sections name payment_validator upstream
reading that process on billing-1, then asking it about itself
-> small mailbox, high running time, its state points at an external call
goroutines on billing-1, once, filtered
-> parked in external_api.Call(). The payment API is the bottleneck.# Install the project generator
go install ergo.tools/ergo@latest
# Create a project
ergo init AgentNode github.com/myorg/agentnode
cd agentnode
# Add components
ergo add supervisor AgentNodeApp:AgentSup
ergo add actor AgentSup:ResearchAgent
ergo add actor AgentSup:AnalysisAgent
# Run
go run ./cmdimport "ergo.services/application/observer"
options.Applications = []gen.ApplicationBehavior{
agentnodeapp.CreateApp(),
observer.CreateApp(observer.Options{Port: 9911}),
}claude mcp add --transport http ergo http://localhost:9911/mcp2024-07-31 07:53:57 [info] 6EE4478D: node started successfully2024-07-31 07:53:57 [info] <6EE4478D.0.1017>: processing request2024-07-31 07:53:57 [info] <6EE4478D.0.1017> 'worker': processing request2024-07-31 07:53:57 [info] <6EE4478D.0.1017> 'worker' main.MyWorker: processing request2024-07-31 07:53:57 [info] Alias#<6EE4478D.123663.24065.0>: handling HTTP request2024-07-31 07:53:57 [info] 6EE4478D-90A29F11: connection establishedfunc (a *OrderProcessor) HandleMessage(from gen.PID, message any) error {
order := message.(Order)
a.Log().AddFields(
gen.LogField{Name: "order_id", Value: order.ID},
gen.LogField{Name: "customer_id", Value: order.CustomerID},
)
a.Log().Info("processing order")
a.Log().Debug("validating payment")
return nil
}2024-11-12 15:30:45 [info] <6EE4478D.0.1017>: processing order
fields order_id:12345 customer_id:67890
2024-11-12 15:30:45 [debug] <6EE4478D.0.1017>: validating payment
fields order_id:12345 customer_id:67890{"time":1788430075197809000,"level":"info","source":{"type":"node","node":"B6473ECD"},
"message":"processing order","fields":{"order_id":"12345","paid":"true"}}a.Log().DeleteFields("order_id", "customer_id")a.Log().AddFields(gen.LogField{Name: "session_id", Value: "abc123"})
a.Log().PushFields()
a.Log().AddFields(gen.LogField{Name: "operation", Value: "payment"})
a.Log().Info("processing payment")
a.Log().PopFields()
a.Log().Info("payment complete")2024-11-12 15:30:45 [info] <6EE4478D.0.1017>: processing payment
fields session_id:abc123 operation:payment
2024-11-12 15:30:45 [info] <6EE4478D.0.1017>: payment complete
fields session_id:abc123options.Log.DefaultLogger.TimeFormat = time.DateTime
options.Log.DefaultLogger.IncludeBehavior = true
options.Log.DefaultLogger.IncludeName = true
options.Log.DefaultLogger.IncludeFields = truetype LoggerBehavior interface {
Log(message MessageLog)
Terminate()
}node.LoggerAdd("errors", errorLogger, gen.LogLevelError, gen.LogLevelPanic)type MyLogger struct {
act.Actor
}
func (ml *MyLogger) HandleLog(message gen.MessageLog) error {
switch m := message.Source.(type) {
case gen.MessageLogNode:
// Handle node log
case gen.MessageLogProcess:
// Process log - has PID, name, behavior
case gen.MessageLogMeta:
// Meta process log - has Alias
case gen.MessageLogNetwork:
// Network log - has local and remote nodes
}
return nil
}pid, err := node.Spawn(createMyLogger, gen.ProcessOptions{})
node.LoggerAddPID(pid, "mylogger", gen.LogLevelError, gen.LogLevelPanic)options.Log.DefaultLogger.Disable = true
coloredLogger := colored.CreateLogger(colored.Options{})
node.LoggerAdd("console", coloredLogger,
gen.LogLevelDebug, gen.LogLevelInfo, gen.LogLevelWarning,
gen.LogLevelError, gen.LogLevelPanic)
rotateLogger := rotate.CreateLogger(rotate.Options{Path: "/var/log/myapp"})
node.LoggerAdd("file", rotateLogger) // No filter = all levels// Debugging a specific process
node.SetProcessLogLevel(suspiciousPID, gen.LogLevelDebug)
// Later, restore normal level
node.SetProcessLogLevel(suspiciousPID, gen.LogLevelInfo)// Register hidden logger for trading
tradingFileLogger := rotate.CreateLogger(rotate.Options{Path: "/var/log/trading"})
node.LoggerAdd(".trading", tradingFileLogger)
// Trading process uses only the hidden logger
tradingProcess.Log().SetLogger(".trading")# Node-specific integer configuration (stored as string, converted to int64)
etcdctl put services/ergo/cluster/production/config/web1/database.port "int:5432"
# Cluster-wide float configuration (stored as string, converted to float64)
etcdctl put services/ergo/cluster/production/config/*/cache.ratio "float:0.75"
# Boolean configuration (stored as string, converted to bool)
etcdctl put services/ergo/cluster/production/config/*/debug.enabled "bool:true"
etcdctl put services/ergo/cluster/production/config/web1/ssl.enabled "bool:false"
# Application-specific configuration (visible to all nodes using wildcard format)
etcdctl put services/ergo/cluster/production/config/*/myapp.cache.size "int:256"
etcdctl put services/ergo/cluster/production/config/*/client.timeout "int:30"
# Global string configuration (stored and returned as string)
etcdctl put services/ergo/config/global/log.level "info"registrar, err := node.Network().Registrar()
if err != nil {
return err
}
// Get single configuration item
port, err := registrar.ConfigItem("database.port")
if err != nil {
return err
}
// port will be int64(5432)
// Get multiple configuration items
config, err := registrar.Config("database.port", "cache.ratio", "debug.enabled", "log.level")
if err != nil {
return err
}
// config["database.port"] = int64(5432)
// config["cache.ratio"] = float64(0.75)
// config["debug.enabled"] = bool(true)
// config["log.level"] = "info"type myActor struct {
act.Actor
}
func (m *myActor) HandleMessage(from gen.PID, message any) error {
reg, err := m.Node().Network().Registrar()
if err != nil {
m.Log().Error("unable to get Registrar interface: %s", err)
return nil
}
ev, err := reg.Event()
if err != nil {
m.Log().Error("Registrar has no registered Event: %s", err)
return nil
}
m.MonitorEvent(ev)
return nil
}
func (m *myActor) HandleEvent(event gen.MessageEvent) error {
switch msg := event.Message.(type) {
case etcd.EventNodeJoined:
m.Log().Info("Node %s joined cluster", msg.Name)
case etcd.EventApplicationStarted:
m.Log().Info("Application %s started on node %s", msg.Name, msg.Node)
case etcd.EventConfigUpdate:
m.Log().Info("Configuration %s updated", msg.Item)
// Handle specific configuration changes
if msg.Item == "ssl.enabled" {
if enabled, ok := msg.Value.(bool); ok {
m.Log().Info("SSL %s", map[bool]string{true: "enabled", false: "disabled"}[enabled])
}
}
}
return nil
}type ApplicationRoute struct {
Node Atom
Name Atom
Weight int
Mode ApplicationMode
Tags []Atom
State ApplicationState
Version Version
}resolver := registrar.Resolver()
// Resolve application routes
routes, err := resolver.ResolveApplication("web-server")
if err != nil {
return err
}
for _, route := range routes {
log.Printf("Application %s running on node %s (weight: %d, state: %s)",
route.Name, route.Node, route.Weight, route.State)
}nodes, err := registrar.Nodes()
if err != nil {
return err
}
for _, nodeName := range nodes {
log.Printf("Node in cluster: %s", nodeName)
}services/ergo/cluster/{cluster}/
├── routes/ # Non-overlapping with config paths
│ ├── nodes/{node} # Node registration with lease (edf.Encode + base64)
│ └── applications/{app}/{node} # Application routes (edf.Encode + base64)
└── config/ # Configuration data (string + type prefixes)
├── {node}/{item} # Node-specific config
└── */{item} # Cluster-wide config
services/ergo/config/
├── {cluster}/{node}/{item} # Cross-cluster node config
└── global/{item} # Global config# Start etcd for testing
make start-etcd
# Run tests with coverage
make test-coverage
# Run integration tests only
make test-integration
# Clean up
make clean# Check cluster health
etcdctl --endpoints=localhost:12379 endpoint health
# List all keys in cluster
etcdctl --endpoints=localhost:12379 get --prefix "services/ergo/"
# Set configuration manually (values must be strings)
etcdctl --endpoints=localhost:12379 put \
"services/ergo/cluster/production/config/web1/database.timeout" "int:30"
etcdctl --endpoints=localhost:12379 put \
"services/ergo/cluster/production/config/web1/debug.enabled" "bool:true"
# Watch for changes
etcdctl --endpoints=localhost:12379 watch --prefix "services/ergo/cluster/production/"node, err := ergo.StartNode("worker@localhost", gen.NodeOptions{
Network: gen.NetworkOptions{
Flags: gen.NetworkFlags{
Enable: true,
EnableRemoteSpawn: true, // allow remote nodes to spawn processes
},
},
})network := node.Network()
err := network.EnableSpawn("worker", createWorker)
if err != nil {
// handle error
}// Allow only these nodes to spawn workers
network.EnableSpawn("worker", createWorker,
"scheduler@node1",
"scheduler@node2",
)// Add more nodes to the allowed list
network.EnableSpawn("worker", createWorker,
"scheduler@node1",
"scheduler@node2",
"scheduler@node3", // newly allowed
)// Remove specific nodes
network.DisableSpawn("worker", "scheduler@node2")// No nodes can spawn workers anymore
network.DisableSpawn("worker")// Re-enable for all nodes
network.EnableSpawn("worker", createWorker) // no node argumentsnetwork := node.Network()
remote, err := network.GetNode("worker@otherhost")
if err != nil {
return err // node unreachable, no route, etc
}pid, err := remote.Spawn("worker", gen.ProcessOptions{})
if err != nil {
// handle error - not allowed, factory not found, remote node terminated, etc
}
// pid is the process running on the remote node
process.Send(pid, WorkRequest{Job: "process-data"})pid, err := remote.Spawn("worker", gen.ProcessOptions{},
ConfigData{WorkerID: 42, BatchSize: 100},
)pid, err := remote.SpawnRegister("worker-001", "worker", gen.ProcessOptions{})pid, err := process.RemoteSpawn("worker@otherhost", "worker", gen.ProcessOptions{})pid, err := process.RemoteSpawnRegister(
"worker@otherhost",
"worker",
"worker-001", // registration name
gen.ProcessOptions{},
)// terminates the remote process even with the trap enabled
process.SendExit(pid, gen.TerminateReasonShutdown)node, err := ergo.StartNode("myapp@localhost", gen.NodeOptions{
Security: gen.SecurityOptions{
ExposeEnvRemoteSpawn: true, // allow env inheritance for remote spawn
},
})network := node.Network()
route := gen.NetworkRoute{
Route: gen.Route{
Host: "10.0.1.50",
Port: 4370,
TLS: true,
},
}
err := network.AddRoute("prod-db@example.com", route, 100)
if err != nil {
// handle error
}// Exact match - only this specific node
network.AddRoute("database@prod", route1, 100)
// Prefix match - all production nodes
network.AddRoute("prod-.*", route2, 100)
// Suffix match - all nodes in a domain
network.AddRoute(".*@example.com", route3, 100)
// Complex pattern - production databases only
network.AddRoute("^prod-db[0-9]+@example.com$", route4, 100)route := gen.NetworkRoute{
Route: gen.Route{
Host: "192.168.1.100",
Port: 4370,
TLS: true,
HandshakeVersion: handshake.Version(), // optional, uses default if not set
ProtoVersion: proto.Version(), // optional, uses default if not set
},
}route := gen.NetworkRoute{
Resolver: registrar.Resolver(), // ask this registrar for these nodes
}
network.AddRoute("staging-.*", route, 100)route := gen.NetworkRoute{
Route: gen.Route{
Host: "partner.external.com",
Port: 4370,
},
Cookie: "shared-secret-with-partner",
}customCert := node.CertManager() // or create a new one
route := gen.NetworkRoute{
Route: gen.Route{
Host: "secure.partner.com",
Port: 4370,
TLS: true,
},
Cert: customCert,
}route := gen.NetworkRoute{
Route: gen.Route{
Host: "readonly.external.com",
Port: 4370,
},
Flags: gen.NetworkFlags{
Enable: true,
EnableRemoteSpawn: false, // don't let them spawn on us
EnableRemoteApplicationStart: false, // don't let them start apps on us
EnableImportantDelivery: true, // but do support important delivery
},
}route := gen.NetworkRoute{
Route: gen.Route{
Host: "legacy.system.com",
Port: 4370,
},
AtomMapping: map[gen.Atom]gen.Atom{
"mynode@localhost": "legacy_node",
"process_manager": "proc_mgr",
},
}route := gen.NetworkRoute{
Route: gen.Route{
Host: "debug.target.com",
Port: 4370,
},
LogLevel: gen.LogLevelTrace, // detailed logging for this route only
}// Primary datacenter - wider pattern
primaryRoute := gen.NetworkRoute{
Route: gen.Route{Host: "10.0.1.50", Port: 4370, TLS: true},
}
network.AddRoute("^prod-db@.*", primaryRoute, 200)
// Backup datacenter - more specific pattern
backupRoute := gen.NetworkRoute{
Route: gen.Route{Host: "10.0.2.50", Port: 4370, TLS: true},
}
network.AddRoute("prod-db@example.com", backupRoute, 100)// These are different patterns that match the same node
network.AddRoute("^prod-db@example.com$", primaryRoute, 200) // exact match with anchors
network.AddRoute("prod-db@example.com", backupRoute, 100) // substring matchroutes, err := network.Route("prod-db@example.com")
if err == gen.ErrNoRoute {
// no static route defined
} else {
// routes contains all matching routes, sorted by weight descending
for i, route := range routes {
fmt.Printf("Route %d: %s:%d\n", i+1, route.Route.Host, route.Route.Port)
}
}err := network.RemoveRoute("prod-db@example.com")
if err == gen.ErrUnknown {
// no such route existed
}proxyRoute := gen.NetworkProxyRoute{
Route: gen.ProxyRoute{
To: "backend-db@internal.local", // final destination
Proxy: "gateway@dmz.example.com", // intermediate node
},
}
network.AddProxyRoute("backend-.*@internal.local", proxyRoute, 100)proxyRoute := gen.NetworkProxyRoute{
Route: gen.ProxyRoute{
To: "target@backend",
Proxy: "gateway@dmz",
},
Cookie: "gateway-specific-cookie", // authenticate to gateway
MaxHop: 3, // intended chain-depth limit
Flags: gen.NetworkProxyFlags{
Enable: true,
EnableRemoteSpawn: false,
EnableRemoteApplicationStart: false,
EnableEncryption: true,
EnableImportantDelivery: true,
},
}route := gen.NetworkRoute{
Resolver: etcdRegistrar.Resolver(),
Route: gen.Route{
TLS: true, // force TLS even if resolver says otherwise
},
}
network.AddRoute("prod-.*", route, 100)route := gen.NetworkRoute{
Route: gen.Route{
Host: "10.0.1.50",
Port: 4370,
TLS: true,
},
}
network.AddRoute("prod-db@example.com", route, 100)node, err := ergo.StartNode("myapp@localhost", gen.NodeOptions{
Network: gen.NetworkOptions{
Flags: gen.NetworkFlags{
// ... other flags ...
EnableSoftwareKeepAlive: 15, // send keepalive every 15 seconds when idle
},
SoftwareKeepAliveMisses: 3, // tolerate 3 missed keepalives before disconnect
},
})node, err := ergo.StartNode("myapp@localhost", gen.NodeOptions{
Network: gen.NetworkOptions{
Flags: gen.NetworkFlags{
EnableFragmentation: true, // default: true
},
FragmentSize: 65000, // bytes per fragment, 0 = default
FragmentTimeout: 30, // seconds, assembly timeout, 0 = default
MaxFragmentAssemblies: 1000, // max concurrent assemblies, 0 = default
},
})node, err := ergo.StartNode("myapp@localhost", gen.NodeOptions{
Network: gen.NetworkOptions{
Mode: gen.NetworkModeEnabled,
Cookie: "secret-cluster-cookie",
MaxMessageSize: 10 * 1024 * 1024, // 10MB
Flags: gen.NetworkFlags{
Enable: true,
EnableRemoteSpawn: true,
EnableRemoteApplicationStart: true,
EnableImportantDelivery: true,
EnableFragmentation: true, // default: true
EnableSoftwareKeepAlive: 15, // seconds, 0 to disable
},
SoftwareKeepAliveMisses: 3, // tolerate 3 missed keepalives
FragmentSize: 65000, // 0 = default
FragmentTimeout: 30, // seconds, 0 = default
Acceptors: []gen.AcceptorOptions{
{
Port: 15000,
PortRange: 10,
BufferSize: 64 * 1024,
},
},
},
})flags := gen.DefaultNetworkFlags
flags.EnableRemoteSpawn = false
options.Network.Flags = flagsnode, err := ergo.StartNode("myapp@localhost", gen.NodeOptions{
Network: gen.NetworkOptions{
Handshake: customHandshake,
Proto: customProto,
Acceptors: []gen.AcceptorOptions{
{Port: 15000, Proto: ergoProto}, // Ergo protocol
{Port: 16000, Proto: erlangProto}, // Erlang protocol
},
},
})remote, err := node.Network().GetNode("worker@otherhost")
if err != nil {
return err
}
pid, err := remote.Spawn("worker_name", gen.ProcessOptions{})// On the remote node
node.Network().EnableSpawn("worker_name", createWorker)remote.ApplicationStart("myapp", gen.ApplicationOptions{})node.Network().EnableApplicationStart("myapp")SubjectSubjectsub.ShouldSend(...) is a method on it. The options are the real gen.ProcessOptions (set the log level there, for instance, with LogLevel), and any trailing arguments are forwarded to Init, exactly as gen.Node.Spawn forwards them. When you need more than a default node - a node name, seeded environment, an injected dependency - build it first with unit.StartNode(...) and spawn on that; it mirrors how Notice what did not happen after SendMessage: no wait. The handler ran inline, the send was recorded during that run, and the assertion read a finished result. Hold on to that - it is the whole reason unit tests are fast and never flake, and it is the one thing that changes when you move up to stage.
SendMessage is one of a family of drivers, one for every way a message reaches an actor, so you can exercise each callback in isolation:
SendMessage / SendMessageName / SendMessageAlias
HandleMessage and its name/alias split-handlers
SendMessageWithPriority
a message at a given queue priority
Call / CallName / CallAlias / CallWithPriority
A request is the one driver that hands a value back: Call drives the actor's HandleCall and returns what it responded.
Keep the direction straight here, because it is the most common source of confusion in unit tests: Call is you calling into the actor. Controlling what the actor's own outbound calls return is a different tool, OnCall, which comes up below. (One related subtlety: a message the actor sends to itself is recorded as an outgoing send and does not loop back into its mailbox on its own. Drain delivers it, which comes up right after the drivers.)
FireTimers is what makes time-driven behavior deterministic - a periodic tick, a timeout, a retry back-off, a TTL - so a test never sleeps to watch one elapse. Three things it deliberately does not do. It fires every pending timer at once rather than stepping to the next, and a timer the handler re-arms is left for the following call. A timer aimed at another process is marked fired but not delivered, because that message is outward work: assert it with ShouldSendAfter, or ShouldSendEvery for a periodic one. And it advances no clock, so an actor comparing time.Now() against a stored timestamp still needs the wall time to have passed - give such a test a millisecond timeout rather than a mocked hour.
The idiomatic actor does almost nothing in Init. Not because it cannot: Call, Link, Monitor and RegisterName all work there - their state gate admits Init alongside Running, and the process is registered before Init runs precisely so that it can. The reason is that work done in Init holds up the spawn, so a slow or absent dependency turns a start-up into a stall. An actor that needs any of them posts a message to itself and does the real setup in the handler that receives it. A live node delivers that message, and the chain it starts, with no help. In unit nothing is delivered until the test asks, and Drain is the ask:
Drain returns how many messages it delivered and keeps going while handlers post further messages to themselves, so a chain of hops costs one call. Step delivers a single hop, for when the state in the middle of a chain is the thing under test. Self-addressed means by PID, by the actor's registered name, or by one of its aliases; anything the actor sent elsewhere is recorded and never looped back. Priority is honored the way a live mailbox honors it - an urgent self-send is handled before a normal one queued earlier - and draining stops if the actor terminates mid-chain. The self-send is still recorded, so "did it schedule its own startup" stays assertable with ShouldSend.
An actor never runs in a vacuum: it calls out to dependencies, and it reads things about itself and its node. To test it in isolation you control both sides of that world, and unit gives you a distinct tool for each. What the actor does outward - the calls and sends it makes - you shape with typed stubs. What the actor reads - its environment, its node, service discovery - you supply with overrides. Everything in the next two sections is one or the other; keeping that split in mind is most of what it takes to write a unit test confidently.
An actor that calls a dependency cannot be tested alone unless you decide what the call returns. OnCall does that - it intercepts the actor's outbound Call and answers it - which is what lets you drive both branches of the same handler:
Make the call fail, and the error branch runs instead:
Calls are not the only outward action with a return value. Spawning a child, allocating an alias, registering an event, spawning on a remote node - each has a typed stub for either outcome, and the error-only operations (Send, Link, Monitor, SendExit, and the like) take a Fail. FailFunc makes failure selective - a counter in the closure can fail only the second and fifth send while the rest succeed:
Subscribing to an event carries a return value that is easy to overlook. LinkEvent and MonitorEvent hand back the producer's buffered events, the catch-up a new subscriber receives (see Events). An actor that answers a client from that catch-up instead of waiting for the next publish has a branch reachable only by deciding what the subscription returned:
Whether the actor registered its own event as notifying, and with what buffer, is a question for the records rather than the stubs: ShouldRegisterEvent filters on Notify, Buffer and Open, so "it asked to be told when the first subscriber arrives" is assertable on its own.
Two things hold for every stub. Whatever it decides, the action is still recorded - the stub shapes the return value, it does not hide the send from ShouldSend. And a stub you never set is mostly permissive: a value-producing operation returns a synthetic value, an error-only one succeeds.
The exceptions fail the test loudly rather than defaulting, because for these a made-up answer would send the actor down a path you did not choose:
an unstubbed outbound Call - the response drives the caller's logic, so there is no sensible default. All seven Call variants go through the same strict route, and the failure names the stub to add: OnCall(...).Respond(...) or .Fail(...).
an unstubbed resolve or resolve-application through the mock network - a forgotten discovery stub is almost always a bug.
a Node() method the mock does not implement - it tells you to override it with sub.Node().On...(...), or to use stage instead.
A stub only answers a call made after you set it. For a call from a handler that is enough - set the stub after spawn, before the input that triggers it, as above. But some actors call a dependency in Init itself: a supervisor that registers with a service as it starts, for one. By the time Spawn returns, Init has already run, so a stub set on the returned Subject is too late.
Split the spawn for that case. Prepare builds the actor and hands back its Subject without running Init; you set the stubs, then Run runs Init with them in place:
Spawn is exactly Prepare followed by Run, so reach for the split only to stub before Init. Until Run the actor is not initialized: any driver fails the test loudly rather than run against a half-built actor, and calling Run twice fails the same way. The node has its own egress stubs (unit.StartNode(t, ...).OnCall(...)), a separate scope from the actor's: they shape the node's own outbound calls and do not reach the process under test, just as a meta's stubs are its own.
The mirror image is what the actor reads, and it comes from two sources: the actor reads things about itself, and it reads things from its node. Configure either after spawn, before the input that needs it.
What the actor reads about itself is an override on the Subject. Here the actor reads an environment value, and the test decides what it finds:
Every non-egress method the actor calls on itself has such an override - OnState, OnLog, OnUptime, OnInfo, and the rest of its accessors; the godoc has the full set.
What the actor reads from its node is controlled the same way, through sub.Node():
Service discovery is the node read that comes up most often. The node carries a built-in mock network; stub what the actor's resolver returns and you drive its routing decision with no registrar in sight:
FailRegistrar drives the no-registrar branch, and OnGetNode returns a programmable remote node - reaching it is a read, but the Spawn the actor then issues on it is outward work, recorded and asserted as remote egress. Cron jobs the actor adds via sub.Node().Cron().AddJob are recorded and fire only when the test calls FireCron, so a scheduled action stays deterministic.
The node's type registry is modelled rather than stubbed away: RegisterTypes seeds it the way an application's Load does, LookupType and RegisteredTypes read it back, and FailRegisterTypes drives the rejected-registration branch. So "did this actor register the wire surface it needs" is assertable here instead of only in a system test. LookupType matches the canonical #pkgpath/Name key exactly, as a live node does - a short type name does not resolve.
With the world set up and an input driven, you assert on the result. Beyond the record assertions you already know from check, two things are worth calling out.
The PIDs unit hands back are honest. Every spawn gets a distinct, well-formed gen.PID under the node's name - spawn a hundred children and you get a hundred different PIDs, as a real node would. They carry a timestamp-shaped creation the way a live node's do, so a PID a test writes by hand to stand for something foreign stays foreign instead of quietly matching one the harness minted. So you can capture a generated value and assert it flows correctly through later behavior:
And termination is recorded. When a callback returns an error, panics, or returns a stop reason, the actor terminates; assert it with ShouldTerminate, or read it off the Subject:
A panic in a callback is recovered into gen.TerminateReasonPanic, exactly as the real runtime does, so a buggy actor fails its assertion instead of crashing the test. To check an actor's internal field directly, Behavior returns the live behavior for a white-box look, and node-level queries answer truthfully - sub.Node().ProcessInfo(sub.PID()) returns its info, and an unknown PID yields gen.ErrProcessUnknown, just like a real node.
The mock node is not a loose stand-in; it enforces the rules a real process enforces, so a test catches the same misuse production would. Linking a process to itself is rejected with gen.ErrNotAllowed. SetSendPriority validates its argument, and the send priority is stateful - seeded from ProcessOptions and carried by later sends. The logger gates by level, so a line below the configured level is dropped, never recorded. A message addressed by registered name dispatches to the name split-handler. You do not opt into any of this; it is simply how the harness behaves, which is the point - the actor under test runs against the contract it will meet for real.
Meta processes - the I/O adapters behind TCP, UDP, web, and the like - have their own behavior contract, and unit drives them too. SpawnMeta instantiates a meta behavior under the actor, runs its Init, and returns a MetaSubject:
A meta shares its parent's journal and its egress is observed as coming from the parent PID, exactly how the runtime routes it. DeliverMessage, Request, Inspect, and Terminate drive the matching callbacks, and the state gates apply - SendResponse is rejected outside a running callback, just as in production.
A meta that schedules work with SendAfter or SendEvery records the schedule like any other egress, and firing it splits along the same line the runtime draws. A tick the meta addressed to itself is delivered by m.FireTimers(), into its own HandleMessage. A heartbeat it addressed to the parent actor belongs to the parent, so sub.FireTimers() delivers that one, and sub.Drain() carries anything the meta sent the parent outright.
The meta's outbound calls are stubbed on its own scope, not the parent's: m.OnSend and m.OnSpawnMeta configure only this meta, and the parent actor's stubs do not reach it. As with the actor, a stub must be set before the egress happens, so to shape what the meta does in its own Init, prepare it first: sub.PrepareMeta(...) builds the meta without running Init, you set its stubs, then m.Run() runs Init. SpawnMeta is PrepareMeta followed by Run.
Use unit when the thing under test is one actor's decision logic - what it does with a message, how it handles a failure, what it spawns. It is fast, fully deterministic, and it models what stage leaves to the real runtime: termination reasons, scheduled timers, log lines. Most of a suite's tests belong here. When the behavior under test only emerges from the real runtime - supervision and restarts, links and monitors across nodes, cross-node messaging, remote spawn, disconnects - move up to stage. Both speak the same grammar, check, so a test reads the same on either.
sub, err := unit.Spawn(t, factoryWorker, gen.ProcessOptions{})
if err != nil {
t.Fatal(err)
}
sub.SendMessage(client, "ping")
sub.ShouldSend().To(client).Message("pong").Once().Assert()resp, err := sub.Call(client, "status")
check.NoError(t, err)
check.Equal(t, "ready", resp)sub, _ := unit.Spawn(t, factorySession, gen.ProcessOptions{})
sub.Drain() // the Init chain has run, as it would on a live node
check.Equal(t, "warm", sub.Behavior().(*session).state)sub.OnCall(gen.Atom("backend")).Respond("OK")
sub.SendMessage(client, "ping")
sub.ShouldSend().To(gen.Atom("client")).Message("OK").Once().Assert()sub.OnCall(gen.Atom("backend")).Fail(gen.ErrTimeout)
sub.SendMessage(client, "ping")
sub.ShouldSend().To(gen.Atom("logger")).Message("backend failed").Once().Assert()
sub.ShouldSend().To(gen.Atom("client")).None().Assert() // the happy path did not runsub.OnSpawn(factoryWorker).Fail(gen.ErrProcessTerminated)
sub.OnRemoteSpawn("peer@localhost", "svc").Return(remotePID)
i := 0
sub.OnSend(gen.Atom("svc")).FailFunc(func() error {
i++
if i == 2 || i == 5 {
return gen.ErrProcessMailboxFull
}
return nil
})sub.OnMonitorEvent(gen.Event{Name: "metrics"}).Return([]gen.MessageEvent{{Message: 42}})sub := unit.Prepare(t, factoryRadarSup, gen.ProcessOptions{})
sub.OnCall(gen.Atom("radar_health")).Fail(gen.ErrProcessUnknown) // the service is not running
if err := sub.Run(); err != nil {
t.Fatal(err)
}sub.OnEnv(func(name gen.Env) (any, bool) { return "production", true })sub.Node().OnIsAlive(func() bool { return false })sub.Node().Network().Registrar().Resolver().
OnResolveApplication("worker_app").
Return(gen.ApplicationRoute{Node: "node1@localhost", State: gen.ApplicationStateRunning})sub.SendMessage(client, "spawn-worker")
spawn, _ := sub.ShouldSpawn().Once().Capture()
sub.ShouldSend().To(gen.Atom("manager")).Message(spawn.Child).Once().Assert()sub.SendMessage(client, "self-destruct")
sub.ShouldTerminate().Reason(errBoom).Once().Assert()
check.True(t, sub.Terminated())m, err := sub.SpawnMeta(&echoMeta{}, gen.MetaOptions{})
m.DeliverMessage(sub.PID(), "hello")
m.ShouldSend().To(gen.Atom("client")).Message("got:hello").Once().Assert()The agent reads the real thing. The same counters, mailboxes, links, logs and profiles the framework maintains for itself, not a metrics summary sampled a minute ago.
It can act, if you let it. Set a log level, send a message, restart an application, retune a process on the fly. All of it behind a permission model you configure, and off by default on a read-only listener.
The endpoint is /mcp on any listener that serves it, which by default is the same one serving the UI:
Behind a proxy that authenticates, pass what it expects:
That is the whole setup. The surface tells the client where to start and what it may do, so there is no tool list to maintain on your side.
Worth setting once: Instructions in The MCP surface. It is where you write down what no amount of inspection reveals - which node runs which part of the business, where a flow begins, what must not be touched. The agent is told this before it asks anything.
Thirty-eight tools. Twenty-nine read, nine change something. Every tool that asks about a node takes a node argument, so any question can be aimed at any node in the cluster. The four that are not about a single node are the exception: cluster_query takes a list of nodes, cluster_batch names a node per step, and job_list and job_cancel are about runs.
The node itself
node
What the node is right now: uptime, memory, process and application counts, version, environment
network
The network as configured and as running: acceptors, flags, registrar, whether the stack is stopped
connections
Processes
processes
Every process on the node: what it is, what it runs under, how much it has handled, what it is doing now
process_state
What one process says about itself - the sections its behavior chooses to expose, such as a supervisor's restart count
process_lookup
The full framework-level record of a single process is a reading rather than a tool: ergo://<node>/process/<pid>, with its mailbox depths and latencies, links, monitors, aliases and delivery settings.
Applications
applications
The applications on the node, running or merely loaded, with mode, weight, published roles and process count
app_tree
Every process running under one application
Events (pub/sub)
events
Every event on the node: who produces it, how many subscribe, whether it buffers, who may publish
event
One event: its producer, its buffer, subscriber count, when it last published
Diagnostics that cost something
goroutines
A goroutine dump, filterable by stack text, state or wait time
heap_profile
What is allocated and by which call path
Both stop the world on the node they run on for as long as the walk takes. Ask once, read carefully, never in a loop and never fanned out across a cluster.
Scheduling
cron
The cron jobs: spec, timezone, when each last ran and what it left behind
cron_schedule
What the node will run, and when
Service discovery
registrar_nodes
Every node registered with the service registry this node uses
registrar_routes
How to reach one node: host, port, TLS, the versions it speaks
registrar_application_routes
The wire
types
The message types registered for the network, with per-type encode and decode counters
errors
The sentinel errors that node can carry over the network
atoms
These answer the question a distributed-systems bug eventually raises: can these two nodes actually understand each other.
Anything above that describes a whole thing is also addressable, as ergo://<node>/<lens> - the node, its processes, its network, one process, one event. The agent reads an address, and reads the same address again later to see what moved, which is how it watches a mailbox drain instead of guessing.
Three of those readings accumulate rather than answer once:
ergo://<node>/log
The lines the node logs, filtered by level or pattern on the node itself
ergo://<node>/stream/{event}
The actual messages flowing through one event
ergo://<node>/tracing
They start collecting when first read, so the first answer is usually near-empty and the second one has what happened in between. This is what makes "watch it and tell me when it recovers" a thing an agent can actually do.
cluster_query
Asks one tool of many nodes at once, in parallel
cluster_batch
Runs different questions on different nodes at once - each step names its own node, tool and arguments
job_list
Both answer immediately with the address of a run, and the answers land as nodes report. A slow node does not hold up the others, and an unreachable one comes back as refused rather than waited for. ergo://cluster is the cheaper question when all you need is who is up, how long they have been up, and who fell out and why.
send
Delivers one message to a process or a meta
send_exit
Asks a process to stop, so it runs Terminate
kill
process_tune and tracing_sampler_set are the two that change the shape of an investigation: a hypothesis about compression or priority gets tested on the live system, and tracing gets switched on for the one process that matters, without a deploy.
The permission model is the listener's, not the agent's. Set Ceiling: observer.Ceiling{ReadOnly: true} and the nine tools above are not merely refused - they are not offered, so an agent never plans around them. A finer ceiling can deny individual operations, or restrict which nodes are reachable at all.
Two habits are worth asking of an agent operating production: call capabilities before planning anything that writes, and act on one thing at a time. A kill is not reversible, and fanning a mutating tool across the cluster is deliberately not offered.
For the full configuration - listeners, surfaces, ceilings, authorizers - see Observer.
A symptom in plain words: orders are slow since the deploy. What follows is the shape of the work.
Where are we. ergo://cluster: fifteen nodes, three named orders@*, one restarted four minutes ago. That restart is the first thing worth explaining.
Ask all three at once. cluster_query with processes over the orders@* nodes, narrowed to processes holding a real backlog. Two look ordinary. The restarted one has a process with a mailbox in the thousands.
Read that process. ergo://orders-2@host/process/<pid>: the mailbox is deep and it has been running for tens of seconds.
Ask it about itself. process_state on the same pid - and here the actor names the upstream it is waiting on, which no generic reading would have told you.
Follow it. The upstream is a process on another node. Modest mailbox, high running time, its own state pointing at an external call. Nothing is queueing there; it is simply slow, and everything behind it is queueing.
Confirm. goroutines on that node, once, filtered. The stacks are parked in the external call. That is the cause: not the node, not the framework, a dependency that got slower.
Watch it recover. Read the process listing and the log again as the dependency comes back, and watch the mailbox drain.
Nothing in that sequence was decided in advance. Each step chose the next from what the previous one answered.
Observer - adding it to a node, and bounding what an agent may do
Inspecting With Observer - the same node through the web UI
AI Agents - building agents with the framework, and diagnosing them
claude mcp add --transport http ergo http://localhost:9911/mcpclaude mcp add --transport http ergo http://localhost:9911/mcp \
--header "Authorization: Bearer ${TOKEN}"name_gen.go
tool
on every ergo add or ergo generate
factory, Init spec, Load group
User-owned files provide hooks that the generated code calls. The pattern is consistent across all component types:
mysup.go
Tune(spec, args) (SupervisorSpec, error)
adjust supervisor spec before start
myapp.go
Tune(spec, args) (ApplicationSpec, error)
Creates a new project. The directory name is derived from the last segment of the module path. Generates ergo.yaml, all boilerplate, go.mod, and runs go mod tidy.
The default project has one application, one supervisor and one actor, enough to verify everything works before adding real components.
Adds an actor. Parent is the name of an existing supervisor or application. Without a parent the actor is added to node.processes and spawned directly by the node at startup.
--pool generates a pool actor with a companion worker type. A pool distributes incoming messages across a fixed set of workers and restarts them on failure.
Adds a supervisor. Parent is an existing application or supervisor.
The name comes first, and that is not stylistic: add supervisor, add app and add message each read the name from the first argument and look for flags only after it. Lead with a flag and the flag becomes the name - ergo add supervisor --type one_for_one Sup records a supervisor literally called --type, which then fails during generation. Only add actor accepts either order.
--type controls which children are restarted when one fails:
one_for_one (default)
only the failed child
all_for_one
all children
rest_for_one
A supervisor of any type still needs at least one declared child: Init rejects an empty Children list with "children list can not be empty". A freshly generated supervisor has none, so add a child to it - ergo add actor MySup:Worker - before running the node. This bites hardest with simple_one_for_one, where it is tempting to assume the instances alone are enough.
--strategy controls when a child is restarted:
transient (default)
only on abnormal exit
permanent
always
temporary
Adds an application. --mode declares what happens to the application when one of its group members terminates. It has nothing to do with stopping the node:
transient (default)
the application stops if a member exits abnormally
permanent
the application stops when any member exits, with that member's reason
temporary
A stopped application returns to ApplicationStateLoaded and the node keeps running. See Applications for the full lifecycle.
Adds an EDF message type. Field types can be standard Go types (string, int, bool, []byte) or framework types (gen.Alias, gen.PID, gen.Ref).
Generated struct definitions and EDF registration go into messages_gen.go, which is always regenerated when the message list changes.
If a message type has fields of other custom types, add the inner type first. EDF requires nested types to be registered before the types that reference them:
Both nodes must register the same types with identical field definitions. The registration order between nodes does not need to match; nodes negotiate numeric type IDs during handshake.
For detailed coverage of EDF, type constraints, and custom marshaling, see Network Transparency.
Regenerates all *_gen.go files from ergo.yaml. Your .go files are never overwritten. Searches for ergo.yaml in the current directory and its parents.
After ergo init MyNode github.com/myorg/mynode:
The README.md is regenerated on every ergo add or ergo generate and shows the current supervision tree.
mynodesup.go contains Tune, called from the generated Init. The generated Init builds SupervisorSpec from ergo.yaml and passes it to Tune. Override restart parameters or add dynamic children here:
Do not replace spec.Children in Tune unless you have a specific reason. The children list is populated from ergo.yaml by the generated Init.
mynodeapp.go contains Tune, called from the generated Load. The Group in Load is populated from ergo.yaml. Use Tune to set metadata, environment variables or dependencies:
messages.go contains extraMessages(), called from the generated init(). Add custom types that are not declared in ergo.yaml:
For types with unexported fields or special encoding needs, implement edf.Marshaler/Unmarshaler or encoding.BinaryMarshaler/Unmarshaler in a separate file. See Network Transparency.
Some applications cannot be described in ergo.yaml because their constructor requires runtime arguments. Add them in cmd/main.go, which is never regenerated:
Each ergo add updates ergo.yaml, regenerates *_gen.go files, and leaves your .go files untouched.
Observer: web UI, API and MCP surface for inspecting running nodes and processes
Actors: actor types, supervision and messaging patterns
Applications: application lifecycle and modes
Pool: distributing work across worker processes
: EDF serialization and distributed messaging
go install ergo.tools/ergo@latestergo init MyNode github.com/myorg/mynode
cd mynode
go run ./cmdergo init <NodeName> <module>ergo init MyNode github.com/myorg/mynode
ergo init Gateway github.com/acme/api-gatewayergo add actor [--pool] <[Parent:]Name>ergo add actor MySup:MyActor
ergo add actor --pool MySup:RequestPool
ergo add actor StandaloneActorergo add supervisor <[Parent:]Name> [--type <type>] [--strategy <strategy>]ergo add supervisor MyApp:WorkerSup
ergo add supervisor MyApp:CriticalSup --type all_for_one --strategy permanent
ergo add supervisor WorkerSup:SubSup --type rest_for_oneergo add app <Name> [--mode <mode>]ergo add app MyApp
ergo add app BackgroundApp --mode temporary
ergo add app CriticalApp --mode permanentergo add message <Name> --field name:type [--field name:type ...]ergo add message MessageConnect --field ID:gen.Alias --field Addr:string
ergo add message MessageData --field ID:gen.Alias --field Payload:"[]byte"ergo add message MessageAddress --field City:string --field Street:string
ergo add message MessageUser --field Name:string --field Address:MessageAddressergo generate [ergo.yaml]ergo generate
ergo generate /path/to/ergo.yamlmynode/
ergo.yaml project definition
go.mod
go.sum
messages_gen.go EDF struct definitions + registration (generated)
messages.go extraMessages() hook for custom types (yours)
apps/
mynodeapp/
mynodeapp_gen.go CreateApp, Load with Group (generated)
mynodeapp.go Tune, Start, Terminate (yours)
mynodesup_gen.go factory, Init with SupervisorSpec (generated)
mynodesup.go Tune, HandleMessage (yours)
mynodeactor_gen.go factory (generated)
mynodeactor.go Init, HandleMessage, HandleCall (yours)
cmd/
main_gen.go node startup, application list (generated)
main.go extraApps() hook (yours)
README.mdnode:
name: MyNode
module: github.com/myorg/mynode
host: localhost
network:
tls: false
cookie: "" # empty means auto-generated on every start
loggers: # colored, rotate
- colored
apps:
# User-defined application
- name: MyApp
mode: transient
children:
- sup: MySup
type: one_for_one
strategy: transient
intensity: 2 # max restarts within period
period: 5 # seconds
children:
- actor: MyActor
- actor: MyPool
pool: true
# Known applications from the ergo.services ecosystem
- observer
- radar
processes: # spawned directly by node, no application
- actor: StandaloneActor
messages:
- name: MessageConnect
fields:
- ID: gen.Alias
- Addr: stringfunc (sup *MySup) Tune(spec act.SupervisorSpec, args ...any) (act.SupervisorSpec, error) {
spec.Restart.Intensity = 10
spec.Restart.Period = 30
return spec, nil
}func (app *MyApp) Tune(spec gen.ApplicationSpec, args ...any) (gen.ApplicationSpec, error) {
spec.Description = "main application"
spec.Version = gen.Version{Release: "1.0.0"}
spec.Env = map[gen.Env]any{
"DB_HOST": "localhost",
"DB_PORT": 5432,
}
spec.Depends.Applications = []gen.Atom{"config"}
return spec, nil
}func extraMessages() []any {
return []any{
MyCustomMessage{},
AnotherMessage{},
}
}func extraApps() []gen.ApplicationBehavior {
return []gen.ApplicationBehavior{
thirdparty.New(thirdparty.Options{
DSN: os.Getenv("DATABASE_URL"),
Port: 8080,
}),
}
}# 1. Create the project
ergo init OrderService github.com/acme/orders
cd orders
# 2. Verify it runs
go run ./cmd
# 3. Add the supervision tree incrementally
ergo add supervisor MyOrderServiceApp:ApiSup
ergo add actor ApiSup:HttpHandler
ergo add actor --pool ApiSup:RequestPool
ergo add supervisor MyOrderServiceApp:WorkerSup --type all_for_one
ergo add actor WorkerSup:OrderProcessor
ergo add actor WorkerSup:PaymentActor
# 4. Add network message types
ergo add message MessageOrderCreated --field OrderID:string --field Total:int
ergo add message MessageOrderPaid --field OrderID:string
# 5. Implement logic in .go files
# 6. Run, observe, iterate
go run ./cmdErlang network stack
This package implements the Erlang network stack, including the DIST protocol, ETF data format, EPMD registrar functionality, and the Handshake mechanism.
It is compatible with OTP-23 to OTP-29. The source code is available on the project's GitHub page at https://github.com/ergo-services/proto in the erlang23 directory.
The source code is distributed under the MIT License, like the rest of the framework, and is free to use in commercial projects without restrictions.
The epmd package implements the gen.Registrar interface. To create it, use the epmd.Create function with the following options:
Port: Registrar port number (default: 4369).
EnableRouteTLS: Enables TLS for all gen.Route responses on resolve requests. This is necessary if the Erlang cluster uses TLS.
DisableServer: Disables the internal server mode, useful when using the Erlang-provided epmd service.
To use this package, include ergo.services/proto/erlang23/epmd.
The handshake package implements the gen.NetworkHandshake interface. To create a handshake instance, use the handshake.Create function with the following options:
Flags: Defines the supported functionality of the Erlang network stack. The default is set by handshake.DefaultFlags().
UseVersion5: Enables handshake version 5 mode (default is version 6).
To use this package, include ergo.services/proto/erlang23/handshake.
The ergo.services/proto/erlang23/dist package implements the gen.NetworkProto and gen.Connection interfaces. To create it, use the dist.Create function and provide dist.Options as an argument, where you can specify the FragmentationUnit size in bytes. This value is used for fragmenting large messages. 65000 bytes is both the default and the floor: a smaller value is silently raised to it, with no error and no log line, so asking for 8000 gets you 65000.
The Erlang DIST proto deliberately does not implement gen.TypeRegistry, because the Erlang external term format (ETF) carries primitives, atoms, lists, tuples, and binaries directly on the wire without a separate type-registration step. Use etf.RegisterTypeOf (described below) to teach the Erlang decoder how to map incoming tuples or atoms to your Go types.
Note what that means for node.Network().RegisterType and for ApplicationSpec.Network.RegisterTypes: they do not reach the Erlang wire, and they do not tell you so. A node always keeps the native proto registered alongside whatever you configured, so those calls find a TypeRegistry to write into and return nil even on a node that speaks nothing but DIST. An application whose spec lists its wire types therefore loads without complaint, and the types are in the EDF registry that this node never uses. For the Erlang side, etf.RegisterTypeOf is the only registration that counts.
To use this package, include ergo.services/proto/erlang23/dist.
Erlang uses the ETF (Erlang Term Format) for encoding messages transmitted over the network. Due to differences in data types between Golang and Erlang, decoding received messages involves converting the data to their corresponding Golang types:
number -> int64
float number -> float64
big number -> big.Int
These are named types, and a type assertion is exact: a map arrives as etf.Map, so .(map[any]any) fails on it even though etf.Map is defined as map[any]any. The same goes for numbers - every Erlang integer is an int64, so .(int) never matches. Neither mistake produces an error you can see; the assertion just reports false and your code takes whatever branch it has for bad input.
Erlang has no string type, and that leaks into Go. A list of integers between 0 and 255 is sent as STRING_EXT and arrives as a Go string; any other list arrives as an etf.List. So "hello" from an Erlang shell is a Go string, and so is [1,2,3] - it reaches you as "\x01\x02\x03". Meanwhile [1000,2000] is an etf.List{1000, 2000}, because those values do not fit a byte. Nothing announces which of the two you are getting.
etf.TermToString exists for exactly this: it accepts string, etf.List, []byte and gen.Atom and returns the text, so a callback that wants a string can stop caring which shape arrived. When you control both sides, sending a binary (<<"hello">>) instead of a charlist removes the ambiguity altogether: it always arrives as []byte.
When encoding data in the Erlang ETF format:
map -> map #{}
slice/array -> list []
You can also use the functions etf.TermIntoStruct and etf.TermProplistIntoStruct for decoding data. These functions take into account etf: tags on struct fields, allowing the values to map correctly to the corresponding struct fields when decoding proplist data.
To automatically decode data into a struct, you can register the struct type using etf.RegisterTypeOf. This function takes the object of the type being registered and decoding options etf.RegisterTypeOptions. The options include:
Name - The name of the registered type. By default it is taken from the reflect package as # followed by the package path and the type name, for example #github.com/myorg/myapp/MyValue
Strict - Determines whether the data must match the struct. With Strict: false non-matching data is decoded into any
To be automatically decoded the data sent from Erlang must be a tuple, with the first element being an atom whose value matches the type name registered in Golang. For example:
The values sent by an Erlang process should be in the following format:
If you want to use the Erlang network stack by default in your node, you need to specify this in gen.NetworkOptions when starting the node:
In this case, all outgoing and incoming connections will be handled by the Erlang network stack. For a complete example, see the : an application that Erlang drives through gen_server:call, with the type mapping above put to work in one place.
The first thing anyone tries from the Erlang shell is a ping, and it fails on a node that works perfectly:
A gen_server:call to a process on that same node, typed immediately afterwards, answers normally.
ping does not test the connection. It calls the net_kernel process on the other node with {is_auth, node()} and expects yes; on anything else it runs erlang:disconnect_node and returns pang. An Ergo node has no net_kernel, so nothing answers - and the disconnect is why a connection may appear in the log and go again just before the one you actually use. Judge the link by whether messages arrive, not by ping.
The rest of Erlang's introspection is the same story in reverse: observer:start(), recon and the shell's process listings read structures that only a BEAM node has. An Ergo node is not a BEAM node, and the application does not read an Erlang one either - it inspects nodes through the system application every Ergo node runs, which an Erlang node does not have. Each side keeps its own tools; what crosses between them is messages.
If you want to maintain the ability to accept connections from Ergo nodes while using the Erlang network stack as a main one, you need to add an acceptor in the gen.NetworkOptions settings:
Please note that if the list of acceptors is empty when starting the node, it will launch an acceptor with the network stack using Registrar, Handshake, and Proto from gen.NetworkOptions.
If you set options.Network.Acceptors, you must explicitly define the parameters for all necessary acceptors. In the example, acceptorErlang is created with empty gen.AcceptorOptions (the Erlang stack from gen.NetworkOptions will be used), while for acceptorErgo, the Ergo Framework stack (Registrar, Handshake, and Proto) is explicitly defined.
In this example, you can establish incoming and outgoing connections using the Erlang network stack. However, the Ergo Framework network stack can only be used for incoming connections. To create outgoing network connections using the Ergo Framework stack, you need to configure a static route for a group of nodes by defining a match pattern:
For more detailed information, please refer to the section.
If your cluster primarily uses the Ergo Framework network stack by default and you want to enable interaction with Erlang nodes, you'll need to add an acceptor using the Erlang network stack. Additionally, you must define a static route for Erlang nodes using a match pattern:
The erlang23.GenServer actor implements the low-level gen.ProcessBehavior interface, enabling it to handle messages and synchronous requests from processes running on an Erlang node. The following message types are used for communication in Erlang:
regular messages - sent from Erlang using erlang:send or the Pid ! message syntax
cast-messages - sent from Erlang with gen_server:cast
call-requests - from Erlang made with gen_server:call
erlang23.GenServer uses the erlang23.GenServerBehavior interface to interact with your object. This interface defines a set of callback methods for your object, which allow it to handle incoming messages and requests. All methods in this interface are optional, meaning you can choose to implement only the ones relevant to your specific use case:
The callback method HandleInfo is invoked when an asynchronous message is received from an Erlang process using erlang:send or via the Send* methods of the gen.Process interface. The HandleCast callback method is called when a cast message is sent using gen_server:cast from an Erlang process. Synchronous requests sent with gen_server:call or Call* methods are handled by the HandleCall callback method.
If your actor only needs to handle regular messages from Erlang processes, you can use the standard act.Actor and process asynchronous messages in the HandleMessage callback method.
To start a process based on erlang23.GenServer, create an object embedding erlang23.GenServer and implement a factory function for it.
Example:
To send a cast message, use the Cast method of erlang23.GenServer.
To send regular messages, use the Send* methods of the embedded gen.Process interface. Synchronous requests are made using the Call* methods of the gen.Process interface.
Like act.Actor, an actor based on erlang23.GenServer supports the TrapExit functionality to intercept exit signals. Use the SetTrapExit and TrapExit methods of your object to manage this functionality, allowing your process to handle exit signals rather than terminating immediately when receiving them.
Tracing in Ergo Framework records observations locally on each node. To see the complete picture of a trace spanning multiple nodes, you need to send those observations to an external system that assembles them. Pulse exports tracing observations to any OTLP-compatible backend (Grafana Tempo, Jaeger, OpenTelemetry Collector) over HTTP.
Pulse runs as an application on your node. It registers itself as a tracing exporter, receives observations from the framework, batches them, and periodically flushes them to the configured collector. Each node in your cluster runs its own Pulse instance pointing to the same collector, and the backend assembles cross-node traces automatically.
With this configuration, Pulse sends observations to http://tempo:4318/v1/traces using protobuf encoding. The node name (mynode@localhost) is used as the OTLP resource service.name, so the backend groups observations by node.
The metrics actor collects runtime statistics from an Ergo node and exposes them as a Prometheus HTTP endpoint. It runs as a regular process: spawn it, and it starts serving /metrics with node, network, process, and event telemetry.
For application-specific metrics (request rates, business counters), you extend the actor with custom Prometheus collectors.
Actor systems are dynamic. Processes spawn and terminate constantly, messages flow through mailboxes asynchronously, and load depends on message routing and supervision trees. Traditional monitoring (thread pools, request queues) does not capture this. The metrics actor tracks process lifecycle, mailbox pressure, message throughput, event fanout, network traffic, and delivery errors, giving visibility into what the actor runtime is actually doing.
Only Init() is required. All other callbacks have default implementations.
Two patterns for custom metrics:
Periodic collection: implement CollectMetrics()
HandleCall, returning the actor's reply
DeliverExit / DeliverExitMessage
an exit signal on the urgent queue
DeliverDown / DeliverDownMessage
a monitor's down notification
DeliverEvent / DeliverRegistrarEvent
HandleEvent
DeliverLog
HandleLog (actor registered as a logger)
DeliverSpan
HandleSpan (actor registered as a tracing exporter)
Inspect
HandleInspect, returning the reported map
Drain / Step
messages the actor sent to itself: the whole chain, or one hop
FireTimers
scheduled SendAfter and SendEvery messages whose target is the actor
FireCron
a registered cron job's gen.MessageCron
Every peer this node is connected to, what was negotiated with each, how much has crossed it
connection
One peer in full: agreed flags, the connection pool, bytes and messages each way, whether it has since dropped
capabilities
What this observer may do on that node - what the node offers, crossed with what the caller is allowed
Whether a process is alive and what it is now, by registered name or by id
subtree
The processes below one supervisor
meta_state
The state of one meta process - a socket, a listener, a stream
Which nodes publish one application, with the mode and state each reports
registrar_proxy_routes
Which node relays to another when it cannot be reached directly
The atoms it keeps in its wire cache, sent as an id instead of a string
The spans the node emits while tracing is on
The runs you still hold
job_cancel
Stops one
Terminates a process at once, without letting it run Terminate
log_level_set
Changes the log level of the node, one process or one meta
tracing_sampler_set
Turns tracing on or off, for the node or for one process
process_tune
Changes one delivery setting of a running process: send priority, compression, message ordering, important delivery
app_start
Starts an application already loaded on the node
app_stop
Stops a running application
app_unload
Unloads it, so the node no longer knows it
name.go
you
never
Tune, handlers, Start, Terminate
adjust application spec before start
messages.go
extraMessages() []any
register custom EDF message types
cmd/main.go
extraApps() []ApplicationBehavior
add external applications
the failed child and all children started after it
simple_one_for_one
many instances of one declared spec, spawned at runtime with StartChild
never
the application stops once the last member is gone, with reason normal
math/bigint64uint64map -> etf.Map (map[any]any)
binary -> []byte
list -> etf.List ([]any), or string - see below
tuple -> etf.Tuple ([]any) or a registered struct type
atom -> gen.Atom
pid -> gen.PID
ref -> gen.Ref
ref (alias) -> gen.Alias
atom = true/false -> bool
struct -> map with field names as keys (considering etf: tags on struct fields)
registered type of struct -> tuple with the first element being the registered struct name, followed by field values in order.
[]byte -> binary
int*/float*/big.Int -> number
string -> a charlist (STRING_EXT), which is what Erlang calls a string. Above 65535 bytes the encoder refuses it with etf.ErrStringTooLong
etf.String -> binary, for when you want <<"...">> on the Erlang side
etf.Charlist -> a charlist encoded from []rune, so text outside Latin-1 survives
gen.Atom -> atom
gen.PID -> pid
gen.Ref -> ref
gen.Alias -> ref (alias)
bool -> atom true/false
Strict: trueEvent-driven updates: implement HandleMessage() or HandleEvent() to update metrics as events occur. Use when your application produces natural event streams.
Registering the wire types is the caller's job, not the library's. Spawn the actor on a node that does not know them and ProcessInit fails with "metrics.MetricType is not registered on this node". Besides registering on the node as above, they can be declared on the application that hosts the actor, in gen.ApplicationSpec.Network - RegisterTypes: metrics.NetworkTypes() and RegisterErrors: metrics.ErrorTypes() - which is processed before any of its processes spawn.
If you run the metrics actor through radar instead of spawning it yourself, this is already handled: radar declares both sets in its own ApplicationSpec.Network, and application load runs before any of its processes spawn.
Default configuration:
Host: localhost - standalone mode only
Port: 3000 - standalone mode only
Path: /metrics
CollectInterval: 10 seconds
TopN: 50
Host and Port are defaulted only in standalone mode, which is when Options.Shared is nil. In shared mode the primary still starts the HTTP server and passes Host through as given - so an empty Host there is not localhost, it is every interface. Set it explicitly on a shared-mode primary unless exposing the endpoint publicly is what you want. Path, CollectInterval and TopN are defaulted in both modes.
Host determines which interface the HTTP server binds to. Use "localhost" for development, "0.0.0.0" for production/containers.
Port should not conflict with other services. Prometheus conventionally uses 9090, Observer UI defaults to 9911.
TopN controls how many top entries are tracked for each metric group (mailbox depth, utilization, latency for processes; subscribers, published, deliveries for events). Higher values increase Prometheus cardinality.
CollectInterval controls how frequently the actor queries node statistics. Collecting more frequently than your Prometheus scrape interval wastes resources.
Mux accepts an external *http.ServeMux. The metrics actor registers its handler on this mux and skips starting its own HTTP server. Useful for serving metrics alongside other handlers on a single port:
When Mux is set, Host and Port are ignored.
The actor automatically collects metrics without any configuration. All metrics carry a node label identifying the source node.
Uptime, process counts (total, running, zombie), spawn/termination counters, memory (OS used, runtime allocated), CPU time (user, system), application counts, registered names/aliases/events, event publish/receive/delivery counters, and Send/Call delivery error counters (local and remote).
Delivery errors are split by type: ergo_send_errors_local_total and ergo_call_errors_local_total count failures where the target process is unknown, terminated, or has a full mailbox. ergo_send_errors_remote_total and ergo_call_errors_remote_total count connection failures to remote nodes.
Log message count by level (trace, debug, info, warning, error, panic). Counted once before fan-out to loggers.
Connected node count, per-node uptime, message and byte rates (in/out per remote node), cumulative connections established/lost, and per-acceptor handshake error count. Fragmentation metrics per remote node: fragments sent/received, fragmented messages sent/reassembled, assembly timeouts. Compression metrics per remote node: compressed messages sent, bytes before/after compression, decompressed messages received, bytes before/after decompression. Compression ratio (original / compressed) reveals whether compression is effective for each connection.
Requires building with -tags=latency. Measures how long the oldest message has been waiting in each process's mailbox. Provides distribution across ranges (1ms to 60s+), max latency, and top-N processes by latency.
Always active. Counts messages queued in each process's mailbox. Distribution across ranges (1 to 10K+), max depth, and top-N processes by depth. Complementary to latency: depth is "how many messages are waiting", latency is "how long the oldest has been waiting".
Always active. Includes:
Utilization: ratio of callback running time to uptime. Distribution, max, and top-N.
Init time: ProcessInit duration. Max and top-N.
Throughput: messages in/out per process (top-N) and node-level aggregates.
Wakeups and drains: wakeup count and drain ratio (messages processed per wakeup). Drain ratio distinguishes between slow callbacks (drain ~1) and high-throughput batching (drain ~100) at the same utilization level.
Liveness: detects processes stuck in blocking calls. Computed as RunningTime / (Uptime * MailboxLatency). A healthy process has RunningTime growing with activity (high score). A process blocked in a mutex, channel, or IO has RunningTime frozen while uptime and latency keep growing (score drops over time). Zombie processes are excluded (detected separately). Bottom-N surfaces the most stuck processes. Requires -tags=latency.
Always active. Per-event subscriber count, publish/delivery counts, and utilization state (active, on_demand, idle, no_subscribers, no_publishing). See Events for the pub/sub model and Pub/Sub Internals for the shared subscription optimization that affects delivery counters.
For the complete list of metric names, types, labels, and descriptions, see the metrics actor README.
All custom metrics automatically receive a node const label. Do not include "node" in your variable label names.
Any actor on the same node can register and update custom metrics without importing prometheus or embedding the metrics actor:
When the registering process terminates, the metrics actor automatically unregisters all metrics it owned.
For direct access to the Prometheus registry or periodic collection via CollectMetrics:
Registry() returns nil until Init has returned. The actor builds the registry afterwards, because whether it is a private registry or a Shared one is decided by the Options that Init hands back - so m.Registry().MustRegister(...) inside Init panics on a nil pointer. Build the collectors in Init and register them on the first CollectMetrics, which runs later on the actor's own goroutine.
For event-driven updates, implement HandleMessage() instead of CollectMetrics():
Top-N metrics track the N highest (or lowest) values observed during each collection cycle. Unlike gauges or counters, a top-N metric accumulates observations and periodically flushes only the top entries to Prometheus as a GaugeVec. This is useful when you want to identify the most active, slowest, or largest items out of many, without creating a separate time series for each one.
Each top-N metric is managed by a dedicated actor spawned under a SimpleOneForOne supervisor. Registration creates this actor; observations are sent to it asynchronously. On each flush interval the actor writes the current top-N entries to Prometheus and resets for the next cycle.
The to parameter in RegisterTopN is the name of the supervisor managing top-N actors. The to parameter in TopNObserve is the actor name, by convention "radar_topn_" + metricName.
Ordering modes:
metrics.TopNMax: keeps the N largest values (e.g., slowest queries, busiest actors, highest memory usage)
metrics.TopNMin: keeps the N smallest values (e.g., lowest latency, least active processes)
When the process that registered a top-N metric terminates, the actor automatically cleans up and unregisters its GaugeVec from Prometheus.
When used through the Radar application, the supervisor is already wired in and you use radar.RegisterTopN / radar.TopNObserve helpers instead.
A single metrics actor processes messages sequentially. Under high throughput, its mailbox becomes a bottleneck. Shared mode lets multiple metrics actor instances share the same Prometheus registry:
The primary actor starts the HTTP server and collects base metrics. Workers only process custom metric messages. All actors write to the same registry through the shared object. Works well with act.Pool for automatic load distribution.
For dynamic discovery in Kubernetes, use Prometheus service discovery instead of static targets.
The metrics package includes a pre-built Grafana dashboard (ergo-cluster.json) for monitoring Ergo clusters.
Import it in Grafana: Dashboards > Import > upload ergo-cluster.json > select your Prometheus data source. The $node dropdown at the top filters all panels by selected nodes.
The dashboard is organized top-down: Summary row at the top for cluster health at a glance, then Mailbox Latency and Depth for backpressure analysis, then collapsed rows for Events, Process Activity, Processes, Resources, Logging, and Network. The Network row includes compression overview (ratio, rate, percentage), per-node compression ratio, fragmentation rates (cluster and per-node), connectivity strength, and connection events. Each row focuses on a specific aspect of cluster behavior and can be expanded when investigating issues.
For detailed panel descriptions, see the metrics actor README.
The metrics actor integrates with Observer via HandleInspect(). Inspecting the process shows total metric count, HTTP endpoint, collection interval, and current values for all metrics.
When embedding metrics.Actor and overriding HandleInspect(), your keys are merged on top of base inspection data.
If your node needs both Prometheus metrics and Kubernetes health probes, consider the Radar application. It runs the metrics actor and Health actor together on a single HTTP port.
type MyValue struct{
MyString string
MyInt int32
}
...
// register type MyValue with name "myvalue"
etf.RegisterTypeOf(MyValue{}, etf.RegisterTypeOptions{Name: "myvalue", Strict: true})
...> erlang:send(Pid, {myvalue, "hello", 123}).import (
"fmt"
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/proto/erlang23/dist"
"ergo.services/proto/erlang23/epmd"
"ergo.services/proto/erlang23/handshake"
)
func main() {
var options gen.NodeOptions
// set cookie
options.Network.Cookie = "123"
// set Erlang Network Stack for this node
options.Network.Registrar = epmd.Create(epmd.Options{})
options.Network.Handshake = handshake.Create(handshake.Options{})
options.Network.Proto = dist.Create(dist.Options{})
// starting node
node, err := ergo.StartNode(gen.Atom(OptionNodeName), options)
if err != nil {
fmt.Printf("Unable to start node '%s': %s\n", OptionNodeName, err)
return
}
node.Wait()
}1> net_adm:ping('ergo@localhost').
pangimport (
"fmt"
"ergo.services/ergo"
"ergo.services/ergo/gen"
// Ergo Network Stack
hs "ergo.services/ergo/net/handshake"
"ergo.services/ergo/net/proto"
"ergo.services/ergo/net/registrar"
// Erlang Network Stack
"ergo.services/proto/erlang23/dist"
"ergo.services/proto/erlang23/epmd"
"ergo.services/proto/erlang23/handshake"
)
func main() {
...
acceptorErlang := gen.AcceptorOptions{}
acceptorErgo := gen.AcceptorOptions{
Registrar: registrar.Create(registrar.Options{}),
Handshake: hs.Create(hs.Options{}),
Proto: proto.Create(),
}
options.Network.Acceptors = append(options.Network.Acceptors,
acceptorErlang, acceptorErgo)
// starting node
node, err := ergo.StartNode(gen.Atom(OptionNodeName), options)...
// starting node
node, err := ergo.StartNode(gen.Atom(OptionNodeName), options)
// add static route
route := gen.NetworkRoute{
Resolver: acceptorErgo.Registrar.Resolver(),
}
match := ".ergonodes.local"
if err := node.Network().AddRoute(match, route, 1); err != nil {
panic(err)
}import (
"fmt"
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/proto/erlang23/dist"
"ergo.services/proto/erlang23/epmd"
"ergo.services/proto/erlang23/handshake"
)
func main() {
var options gen.NodeOptions
// set cookie
options.Network.Cookie = "123"
// add acceptors
acceptorErgo := gen.AcceptorOptions{}
acceptorErlang := gen.AcceptorOptions{
Registrar: epmd.Create(epmd.Options{}),
Handshake: handshake.Create(handshake.Options{}),
Proto: dist.Create(dist.Options{}),
}
options.Network.Acceptors = append(options.Network.Acceptors,
acceptorErgo, acceptorErlang)
// starting node
node, err := ergo.StartNode(gen.Atom(OptionNodeName), options)
if err != nil {
fmt.Printf("Unable to start node '%s': %s\n", OptionNodeName, err)
return
}
// add static route
route := gen.NetworkRoute{
Resolver: acceptorErlang.Registrar.Resolver(),
}
if err := node.Network().AddRoute(".erlangnodes.local", route, 1); err != nil {
panic(err)
}
node.Wait()
}type GenServerBehavior interface {
gen.ProcessBehavior
Init(args ...any) error
HandleInfo(message any) error
HandleCast(message any) error
HandleCall(from gen.PID, ref gen.Ref, request any) (any, error)
Terminate(reason error)
HandleEvent(message gen.MessageEvent) error
HandleInspect(from gen.PID, item ...string) map[string]string
}import "ergo.services/proto/erlang23"
func factory_MyActor() gen.ProcessBehavior {
return &MyActor{}
}
type MyActor struct {
erlang23.GenServer
}func (ma *MyActor) HandleInfo(message any) error {
...
ma.Cast(Pid, "cast message")
return nil
}type ActorBehavior interface {
gen.ProcessBehavior
Init(args ...any) (Options, error)
HandleMessage(from gen.PID, message any) error
HandleCall(from gen.PID, ref gen.Ref, message any) (any, error)
HandleEvent(event gen.MessageEvent) error
HandleInspect(from gen.PID, item ...string) map[string]string
CollectMetrics() error
Terminate(reason error)
}package main
import (
"ergo.services/actor/metrics"
"ergo.services/ergo"
"ergo.services/ergo/gen"
)
func main() {
node, _ := ergo.StartNode("mynode@localhost", gen.NodeOptions{})
defer node.Stop()
// Required. ProcessInit refuses to start on a node that cannot decode the
// registration and update messages this actor accepts.
node.Network().RegisterTypes(metrics.NetworkTypes())
node.Network().RegisterErrors(metrics.ErrorTypes())
node.Spawn(metrics.Factory, gen.ProcessOptions{}, metrics.Options{})
// Metrics available at http://localhost:3000/metrics
node.Wait()
}options := metrics.Options{
Host: "0.0.0.0", // Listen on all interfaces
Port: 9090, // HTTP port
Path: "/metrics", // HTTP path
CollectInterval: 5 * time.Second, // Collection frequency
TopN: 50, // Top-N entries per metric group
}
node.Spawn(metrics.Factory, gen.ProcessOptions{}, options)mux := http.NewServeMux()
metricsOpts := metrics.Options{
Mux: mux,
CollectInterval: 5 * time.Second,
}
node.Spawn(metrics.Factory, gen.ProcessOptions{}, metricsOpts)
healthOpts := health.Options{Mux: mux}
node.SpawnRegister("health", health.Factory, gen.ProcessOptions{}, healthOpts)// Register metrics (sync Call, returns error)
metrics.RegisterGauge(w, "metrics_actor", "db_connections", "Active connections", []string{"pool"})
metrics.RegisterCounter(w, "metrics_actor", "cache_ops", "Cache operations", []string{"op"})
metrics.RegisterHistogram(w, "metrics_actor", "request_seconds", "Latency", []string{"path"}, nil)
// Update metrics (async Send)
metrics.GaugeSet(w, "metrics_actor", "db_connections", 42, []string{"primary"})
metrics.CounterAdd(w, "metrics_actor", "cache_ops", 1, []string{"hit"})
metrics.HistogramObserve(w, "metrics_actor", "request_seconds", 0.023, []string{"/api"})
// Remove a metric (async Send)
metrics.Unregister(w, "metrics_actor", "db_connections")type AppMetrics struct {
metrics.Actor
activeUsers prometheus.Gauge
registered bool
}
func (m *AppMetrics) Init(args ...any) (metrics.Options, error) {
m.activeUsers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "myapp_active_users",
Help: "Current number of active users",
})
return metrics.Options{
Port: 9090,
CollectInterval: 5 * time.Second,
}, nil
}
func (m *AppMetrics) CollectMetrics() error {
if m.registered == false {
if err := m.Registry().Register(m.activeUsers); err != nil {
return err
}
m.registered = true
}
count, err := m.Call(userService, getActiveUsersMessage{})
if err != nil {
m.Log().Warning("failed to get user count: %s", err)
return nil
}
m.activeUsers.Set(float64(count.(int)))
return nil
}func (m *AppMetrics) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case requestCompletedMessage:
m.requestsTotal.Inc()
m.requestLatency.Observe(msg.duration.Seconds())
}
return nil
}// Register a top-N metric (sync Call, returns error)
// TopNMax keeps the N largest values; TopNMin keeps the N smallest
metrics.RegisterTopN(w, "topn_supervisor_name", "slowest_queries", "Slowest DB queries",
10, metrics.TopNMax, []string{"query", "table"})
// Observe values (async Send)
metrics.TopNObserve(w, gen.Atom("radar_topn_slowest_queries"), 0.250, []string{"SELECT ...", "users"})
metrics.TopNObserve(w, gen.Atom("radar_topn_slowest_queries"), 1.100, []string{"JOIN ...", "orders"})shared := metrics.NewShared()
// Primary actor: owns HTTP endpoint and base metrics
primaryOpts := metrics.Options{
Port: 9090,
Shared: shared,
}
// Worker actors: handle custom metric updates only
workerOpts := metrics.Options{
Shared: shared,
}scrape_configs:
- job_name: 'ergo-nodes'
static_configs:
- targets:
- 'localhost:3000'
- 'node1.example.com:3000'
scrape_interval: 15sURL
http://localhost:4318/v1/traces
Full OTLP/HTTP collector URL.
Headers
none
Custom HTTP headers sent with every export request. Use for authentication tokens or routing headers.
BatchSize
512
Maximum number of observations in a batch. When the batch reaches this size, it is flushed immediately.
Pulse starts a pool of worker actors. The pool registers itself as a process-based tracing exporter on the node. When the framework emits an observation matching the configured flags, it delivers the observation to the pool, which distributes it to a worker.
Each worker maintains a batch buffer. Observations accumulate until either the batch reaches BatchSize or FlushInterval elapses, whichever comes first. On flush, the worker converts the batch to OTLP protobuf format and sends it via HTTP POST to the collector.
Each worker has its own HTTP client with persistent connections. Workers operate independently. If one worker's flush is slow (waiting on the network), others continue batching and flushing. This provides throughput resilience under variable network conditions.
If a flush fails (network error, collector down, non-2xx response), the error is logged and the worker continues with the next batch. Observations from the failed batch are lost. This is a deliberate trade-off: retrying failed batches would introduce unbounded memory growth and backpressure that could affect the node's primary workload.
On shutdown, each worker flushes any remaining observations before terminating.
Each Ergo observation becomes one OTLP span. The mapping is deterministic. Any node can compute the OTLP span ID for any observation without coordination.
The OTLP span ID encodes both the Ergo span ID and the observation point:
Where Point is: Sent=1, Delivered=2, Processed=3.
This means the three observations for a single message (Sent, Delivered, Processed) have related but distinct OTLP span IDs. Given any one, you can compute the other two.
There is a fourth point, gen.TracingPointSpan (4) - a business span opened with StartTracingSpan. Pulse exports it in the Processed slot, since a business span has a single observation and anchors its children the way a Processed does; span and message span ids come from disjoint sequences, so the shared slot never collides.
Sent (with parent)
Processed of causing message
"sent because of processing that message"
Sent (root)
none
Sent is the anchor for each message. Delivered and Processed are its children at the same level. Response spans nest under Request.Processed, forming a natural call hierarchy:
Every OTLP span includes framework attributes prefixed with ergo.:
ergo.node : node where the observation was recorded
ergo.from : sender process identity
ergo.to : recipient identity
ergo.kind : Send, Request, Response, Spawn, or Terminate
ergo.point : Sent, Delivered, or Processed
ergo.behavior : actor behavior type name
ergo.message : message type name
ergo.ref : call reference (for Request/Response correlation)
Custom attributes set by the process via SetTracingAttribute and SetTracingSpanAttribute are included as additional OTLP span attributes.
The OTLP span name is formatted as:
For example: OrderProcessor Send.Sent main.ReserveStock.
The OTLP SpanKind depends on both the Ergo kind and the observation point:
Send.Sent
PRODUCER
Send.Delivered
CONSUMER
Send.Processed
The Sent side of a message gets the initiator kind (CLIENT/PRODUCER), while the Delivered/Processed side gets the handler kind (SERVER/CONSUMER). For Response, the roles are inverted: Sent is SERVER (handler sending back), Delivered is CLIENT (caller receiving the answer).
OTLP was designed for request-response services where a span represents a unit of work with a start and end time. Ergo's actor model is different: messages are instantaneous events (sent, delivered, processed), not duration-based operations. Pulse maps each of those to a zero-duration OTLP span placed at the exact timestamp when the event occurred. A business span is the exception - it has a real start and end, so it exports with its actual interval, which is what makes StartTracingSpan the tool for timing a unit of work.
In trace visualization tools (Grafana, Jaeger, Zipkin), these appear as dots on a timeline rather than bars. This is expected. The horizontal distance between dots shows actual timing, and the tree structure shows causality.
Req.Sent to Req.Delivered = network latency from A to B
Req.Delivered to Req.Processed = time B spent handling the request
Req.Processed to Resp.Sent = response creation time
Resp.Sent to Resp.Delivered = network latency from B back to A
For duration-based visualization with timing bars, use the Observer web UI which renders Ergo traces natively.
Each Pulse worker exposes statistics through the standard inspection mechanism. In the Observer process list, find the Pulse worker processes and inspect them to see:
spans_received : total observations received by this worker
spans_exported : total observations successfully exported
export_errors : total failed flush attempts
batch_size : current batch length
These counters help diagnose export problems: if export_errors is growing, the collector may be unreachable or overloaded.
Pulse includes a ready-to-use Grafana dashboard for trace search. Import grafana-tracing.json from the Pulse module into your Grafana instance. During import, Grafana will ask you to select a Tempo datasource.
The dashboard provides a TraceQL filter for searching traces by node, behavior, message type, or any span attribute. Results include columns for service name, ergo.kind, ergo.behavior, and ergo.message. Click any Trace ID to open the full waterfall view.
A minimal Tempo configuration for local development:
Point Pulse at URL: "http://tempo:4318/v1/traces". The option is the full OTLP/HTTP endpoint rather than a host:port pair, and there is no Insecure field - plain HTTP is just an http:// URL. In Grafana, add Tempo as a data source (http://tempo:3200) and use the Explore view to search for traces by trace ID or attributes.
import (
"ergo.services/application/pulse"
"ergo.services/ergo"
"ergo.services/ergo/gen"
)
func main() {
node, err := ergo.StartNode("mynode@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
pulse.CreateApp(pulse.Options{
URL: "http://tempo:4318/v1/traces",
}),
},
})
if err != nil {
panic(err)
}
node.Wait()
}pulse.Options{
URL: "http://tempo:4318/v1/traces", // full collector URL
Headers: map[string]string{ // custom HTTP headers
"Authorization": "Bearer <token>",
},
BatchSize: 512, // flush after N observations
FlushInterval: 5 * time.Second, // max time between flushes
PoolSize: 3, // number of export workers
ExportTimeout: 10 * time.Second, // HTTP request timeout
Flags: gen.TracingFlagSend | // which observations to receive
gen.TracingFlagReceive |
gen.TracingFlagProcs,
}OTLP SpanID = ErgoSpanID << 2 | PointReq.Sent
├── Req.Delivered
└── Req.Processed
└── Resp.Sent
└── Resp.Delivered{behavior} {kind}.{point} {message}Time ─────────────────────────────────────────────────────────►
Node A ● ●
Req.Sent Resp.Delivered
(CLIENT) (CLIENT)
Node B ● ● ●
Req.Delivered Req.Processed Resp.Sent
(SERVER) (SERVER) (SERVER)
├── network ──┤── handling ──┤ ├── network ──┤Time ──────────────────────────────────────►
Node A ●
Send.Sent
(PRODUCER)
Node B ● ●
Send.Delivered Send.Processed
(CONSUMER) (CONSUMER)
├── network ──┤── handling ──┤Time ──────────────────────────────────────────────────────────────────────►
Node A ● ●
Req.Sent Resp.Delivered
Node B ● ● ●
Req.Delivered Req.Processed
Fwd.Sent
Node C ● ● ●
Fwd.Delivered Fwd.Processed
Resp.Sent
├── network ──┤─ handling ─┤── network ──┤─ handling ─┤── network ──┤# tempo.yaml
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
http:
endpoint: "0.0.0.0:4318"
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/walRunning an Ergo node in production typically requires two things: health probes for Kubernetes and a Prometheus metrics endpoint. Setting them up separately means two HTTP servers on two ports, two actor packages to import, and the same wiring code repeated on every node.
Radar bundles both into a single application on one HTTP port. Internally it runs a Health actor for probe endpoints, a Metrics actor for base Ergo telemetry, and a pool of metrics workers for custom metric updates, all behind a shared mux served by one HTTP server. Actors interact with Radar through helper functions in the radar package without importing the underlying packages or knowing the internal actor names.
import (
"ergo.services/application/radar"
"ergo.services/ergo"
"ergo.services/ergo/gen"
)
func main() {
node, _ := ergo.StartNode("mynode@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
radar.CreateApp(radar.Options{Port: 9090}),
},
})
// Health: http://localhost:9090/health/live
// http://localhost:9090/health/ready
// http://localhost:9090/health/startup
// Metrics: http://localhost:9090/metrics
node.Wait()
}With no signals registered, all three health endpoints return 200 with {"status":"healthy"}. The metrics endpoint immediately serves base Ergo metrics. No additional configuration is required for a working production setup.
radar.Options{
Host: "0.0.0.0",
Port: 9090,
HealthPath: "/health",
MetricsPath: "/metrics",
HealthCheckInterval: 2 * time.Second,
MetricsCollectInterval: 15 * time.Second,
MetricsTopN: 100,
MetricsPoolSize: 5,
}Host determines which network interface the HTTP server binds to. Default is "localhost". Use "0.0.0.0" for containerized environments where probes and scraping come from outside the pod.
Port sets the single HTTP port for all endpoints. Default is 9090. Choose a port that does not conflict with your application's own listeners.
HealthPath sets the URL prefix for health probe endpoints. Default is "/health". The actual endpoints become HealthPath+"/live", HealthPath+"/ready", HealthPath+"/startup". Change this when deploying behind a reverse proxy that expects a different path prefix.
MetricsPath sets the URL path for the Prometheus scrape target. Default is "/metrics".
HealthCheckInterval controls how often the health actor checks for expired heartbeats. Default is 1 second. Shorter intervals detect failures faster but increase internal message traffic. For most applications, 1-2 seconds provides a good balance.
MetricsCollectInterval sets how often base Ergo metrics are collected (processes, memory, CPU, network, events). Default is 10 seconds. Align this with your Prometheus scrape interval; collecting more frequently than Prometheus scrapes wastes CPU; collecting less frequently means Prometheus may see stale values.
MetricsTopN limits the number of entries in per-process and per-event top-N metrics tables. Default is 50. Increase this for large nodes with thousands of processes where you need broader visibility into the tail. The collection cost scales linearly with TopN.
MetricsPoolSize sets the number of worker actors in the custom metrics pool. Default is 3. Under normal load, a single worker is sufficient. Increase this if many actors send frequent metric updates and you observe the metrics mailbox growing.
Actors register signals with Radar, specifying which probes the signal affects and an optional heartbeat timeout. The health actor monitors the registering process; if it terminates, all its signals are automatically marked as down.
The signal "postgres" participates in both liveness and readiness probes. If the heartbeat stops arriving (timeout expires) or the process terminates, Kubernetes receives a 503 on both /health/live and /health/ready.
Combine with bitwise OR. A signal registered for ProbeLiveness|ProbeReadiness affects both endpoints independently.
When you can detect failures immediately without waiting for a timeout:
RegisterService and UnregisterService are synchronous calls that return an error on failure. Heartbeat, ServiceUp, and ServiceDown are asynchronous sends (fire-and-forget).
For a detailed explanation of the heartbeat model, failure detection mechanisms, and the HTTP response format, see the actor documentation.
Actors register Prometheus metric collectors and update them through Radar's helper functions. The underlying metrics actor manages the Prometheus registry and HTTP exposition. Registration is synchronous, updates are asynchronous.
All custom metrics automatically receive a node const label set to the node name. Do not include "node" in your variable label names; it will cause a "duplicate label names" registration error.
The labels parameter defines the label names for the metric. When updating, you provide label values in the same order. Pass nil for metrics without labels. The buckets parameter in RegisterHistogram defines histogram bucket boundaries; pass nil for Prometheus default buckets.
Updates are distributed across the worker pool. Under high throughput, multiple actors can send updates concurrently without contending on a single actor's mailbox.
When a process that registered metrics terminates, all its metrics are automatically unregistered from the Prometheus registry. No explicit cleanup is needed. To remove a metric while the process is still running, use radar.UnregisterMetric(process, name).
For a detailed explanation of metric types, the Grafana dashboard, and advanced usage (embedding, shared mode), see the actor documentation.
Top-N metrics track the N highest (or lowest) values observed during each collection cycle and flush them to Prometheus as a GaugeVec. This is useful when you want to identify outliers (slowest queries, busiest workers, largest payloads) without creating a time series per item.
Registration is synchronous (returns error). Observations are asynchronous (fire-and-forget). Each top-N metric is managed by a dedicated actor that accumulates observations and flushes the top entries to Prometheus on the same interval as base metrics collection.
radar.TopNMax: keeps the N largest values (e.g., slowest queries, busiest actors, highest memory)
radar.TopNMin: keeps the N smallest values (e.g., lowest latency, least active processes)
When the process that registered a top-N metric terminates, the metric actor cleans up and unregisters from Prometheus. No explicit teardown needed.
An actor that manages a connection pool reports both health and metrics through Radar:
A single periodic check updates both the health signal and connection pool metrics. If the database becomes unreachable, the heartbeat stops and Kubernetes removes the pod from service. The metrics endpoint continues to show the last known pool state until the pod restarts.
An actor that runs migrations uses the startup probe to prevent premature traffic, and reports progress via a gauge:
While migrations run, the startup probe returns 503, Kubernetes waits, and Prometheus shows the remaining migration count. Once complete, the startup signal is released and liveness/readiness probes take over.
Configure Kubernetes probes and Prometheus scraping to point at the same port:
Prometheus scrape configuration:
Align scrape_interval with MetricsCollectInterval in Radar options. The default collect interval is 10 seconds; scraping more frequently than the collect interval returns identical data.
Radar uses and actors internally. The helper functions in the radar package delegate to these actors by their internal registered names. If you need capabilities beyond what the helpers expose (embedding the metrics actor for direct Prometheus registry access, custom health actor behavior with HandleSignalDown callbacks, or shared mux with additional HTTP handlers), use the underlying actors directly.
Radar is designed for the common case: production nodes that need standard health probes and Prometheus metrics with minimal setup. For advanced scenarios, the building blocks are available as separate packages.
Answers to the questions developers and AI assistants ask most often
Ergo is an open-source Go framework for building concurrent and distributed systems using the actor model. It brings Erlang/OTP design patterns, including isolated processes, supervision trees, and network-transparent messaging, to Go with zero external dependencies.
Yes. Ergo is used in production systems. It supports , , graceful shutdown, panic recovery, and has a comprehensive test suite. The framework has been in active development since 2019.
MIT License. Free to use in commercial projects without restrictions.
Go 1.21 or higher. No other dependencies.
The actor model is a concurrency paradigm where independent units (actors, also called processes) communicate exclusively through message passing. Each actor has private state and processes messages one at a time, so nothing inside one actor needs a mutex.
Go's goroutines and channels are powerful but give you no structure for that: identity, supervision, and addressing across nodes are all yours to build. Ergo provides them - one goroutine and one mailbox per process, message-only communication, sequential handling.
What it does not do is take memory isolation out of your hands. A message between two processes on the same node is handed over as the Go value it is, with no copy: send a map, a slice or a pointer and both processes hold the same memory. Only crossing a node boundary encodes, and that is what copies. Send values, or treat a send as handing over ownership. See
The actor model requires sequential message processing - each actor handles one message at a time in a dedicated goroutine. This eliminates data races within the actor but shifts complexity to the message handling loop: reading from multiple mailbox queues in priority order, dispatching to different handlers based on message type, managing state transitions, converting exit signals to regular messages when trapping is enabled.
You could implement this yourself with gen.ProcessBehavior, but you'd rewrite the same logic for every actor. act.Actor solves this. It implements the low-level gen.ProcessBehavior interface and provides a higher-level act.ActorBehavior interface with straightforward callbacks: Init for initialization, HandleMessage for asynchronous messages, HandleCall for synchronous requests, Terminate
FlushInterval
5s
Maximum time between flushes. Even if the batch is not full, it is flushed after this interval.
PoolSize
3
Number of export workers. Each worker maintains its own HTTP client and batch buffer. Increase if your observation rate exceeds what three workers can export.
ExportTimeout
10s
HTTP request timeout per flush. If the collector doesn't respond within this time, the flush fails and the error is logged.
Flags
Send + Receive + Procs
Which observation types Pulse receives. By default, Pulse receives everything. Set a subset to reduce volume, for example TracingFlagSend to export only Sent observations.
first message in trace
Delivered
Sent of same message
"delivered after sent"
Processed
Sent of same message
"processed after sent"
Terminate.Processed
Processed of parent context
"process terminated" (no Sent for Terminate)
CONSUMER
Request.Sent
CLIENT
Request.Delivered
SERVER
Request.Processed
SERVER
Response.Sent
SERVER
Response.Delivered
CLIENT
Response.Processed
SERVER
Spawn
INTERNAL
Terminate
INTERNAL
radar.ProbeLiveness
/health/live
radar.ProbeReadiness
/health/ready
radar.ProbeStartup
/health/startup
act.ActorEmbed act.Actor in your struct and implement the act.ActorBehavior callbacks you need:
Spawn it like any process:
The factory function is called each time you spawn. Each process gets a fresh instance with its own state. This isolation is fundamental to the actor model - actors share nothing except messages.
act.ActorBehavior defines the callbacks act.Actor will invoke:
All callbacks are optional. act.Actor provides default implementations that log warnings for unhandled messages. Implement only what you need.
Since act.Actor embeds gen.Process, you have direct access to all process methods: Send, Call, Spawn, Link, RegisterName, etc. No need to store references - they're built in.
Init runs once when the process spawns. The args parameter contains whatever you passed to Spawn:
If Init returns an error, the process is cleaned up and removed. Spawn returns immediately with that error. Use this for validation: check arguments, verify resources, refuse to start if preconditions aren't met.
During Init, the process is in ProcessStateInit. All operations are available: Spawn, Send, SetEnv, RegisterName, CreateAlias, RegisterEvent, Link*, Monitor*, Call*, and property setters.
Any resources created during Init (names, aliases, events, links, monitors) are properly cleaned up if initialization fails.
Messages arrive in the mailbox and sit in one of four queues: Urgent, System, Main, or Log. act.Actor processes them in priority order:
Urgent - Maximum priority messages (MessagePriorityMax)
System - High priority messages (MessagePriorityHigh)
Main - Normal priority messages (MessagePriorityNormal, default)
Log - Logging messages (lowest priority)
When a message arrives in Urgent, System, or Main, act.Actor calls HandleMessage:
The return value determines whether the actor continues or terminates:
Return nil to keep running
Return gen.TerminateReasonNormal for clean shutdown
Return any other error to terminate (logged as error)
The from parameter tells you who sent the message. Use it for replies. If you don't need replies, ignore it.
When someone calls process.Call(pid, request), act.Actor invokes your HandleCall:
The error return value controls process termination, not the caller's response:
(result, nil) - Send result to caller, continue running
(result, gen.TerminateReasonNormal) - Send result, then terminate cleanly
(nil, someError) - Terminate immediately with someError (caller times out)
To send an application error to the caller, return it as the result value:
This separation between transport errors (err return from Call) and application errors (result as error) is fundamental to actor communication. See Handle Sync for deeper discussion of error channels and when to use SendResponseError.
Sometimes you can't respond immediately. Maybe you need to query another service, or delegate work to a pool of workers. Return (nil, nil) from HandleCall to defer the response:
The gen.Ref identifies the request. The caller blocks waiting for a response with that ref. You can send the response from any process - the one that received the request, a worker, or even a remote process. Just call SendResponse(callerPID, ref, result).
The ref has a deadline (from the caller's timeout). Check if it's still alive before doing expensive work:
To stop an actor, return a non-nil error from HandleMessage or HandleCall:
Termination reasons:
gen.TerminateReasonNormal - Clean shutdown, not logged as error
gen.TerminateReasonKill - Process was killed via node.Kill(pid)
gen.TerminateReasonPanic - Panic occurred in callback (framework catches it)
gen.TerminateReasonShutdown - Node is stopping (sent by parent or node)
Any other error - Application-specific failure (logged as error)
After termination is triggered, act.Actor calls your Terminate callback:
At this point, the process is in ProcessStateTerminated and has been removed from the node. Most gen.Process methods return gen.ErrNotAllowed. You can still send messages (fire-and-forget), but you can't make calls, create links, or spawn children.
If a panic occurs during Init, HandleMessage, or HandleCall, the framework catches it and terminates the process with gen.TerminateReasonPanic. The Terminate callback still runs, giving you a chance to clean up.
What gets logged is the panic value and one frame - the function, file and line the recovery saw - not a stack trace:
That is usually enough to find the line, but not to see how you got there. For the full picture, build with -tags=norecover and let the panic take the node down with Go's own stack trace, or take a goroutine dump while the actor is still alive.
By default, when an actor receives an exit signal (via SendExit or from a linked process), it terminates immediately. Enable TrapExit to convert exit signals into regular messages:
Exit signal messages:
gen.MessageExitPID - From a process (SendExit or link)
gen.MessageExitProcessID - From a named process link
gen.MessageExitAlias - From an alias link
gen.MessageExitEvent - From an event link
gen.MessageExitNode - From a node link (network disconnect)
Exception: Exit signals from the parent process cannot be trapped. If your parent terminates (and you created a link with LinkParent option or via Link/LinkPID), you terminate regardless of TrapExit. This ensures supervision trees can forcefully terminate subtrees.
Use TrapExit when you want to handle failures gracefully - log them, restart workers, switch to fallback services. Don't use it if you want standard supervision behavior (child fails → parent restarts it).
By default, HandleMessage and HandleCall are invoked regardless of how the process was addressed - by PID, by registered name, or by alias. Enable SetSplitHandle(true) to route based on address type:
The same split applies to HandleCall* variants. Use this when you want different behavior for internal communication (PID) versus public API (registered name) versus temporary sessions (alias).
Most actors don't need this. Leave split handle disabled and use HandleMessage/HandleCall for everything.
If your actor is registered as a logger (via node.AddLogger(pid, level)), it receives log messages in the Log queue:
gen.MessageLog carries Time, Level, Source, Format, Args and Fields. There is no PID field and no preformatted Message: the origin is in Source, which is a gen.MessageLogProcess, gen.MessageLogNode, gen.MessageLogNetwork, gen.MessageLogMeta or gen.MessageLogApplication, and the text is yours to format.
Nor is there a stack trace in the message. A logger that wants one on a panic captures it itself, with runtime.Callers inside HandleLog - that runs synchronously in the framework's recover defer, so the panic frames are still walkable. logger/sentry does exactly this.
Log messages have the lowest priority. They're processed after Urgent, System, and Main are empty. This prevents logging from starving regular message processing.
If your actor subscribed to an event (via LinkEvent or MonitorEvent), it receives event messages:
Events arrive in the queue the producer's send priority selects, not in a queue reserved for events: High puts them in System, Max in Urgent, and anything else - including the default Normal - in Main. So an event is ordinary Main-queue traffic unless the publisher deliberately raised its priority. Use events for cross-cutting concerns where multiple actors need to react to the same occurrence.
Actors can expose runtime state for monitoring and debugging via the HandleInspect callback:
Inspect the actor from within a process context or directly from the node:
Both methods only work for local processes (same node). Inspection requests go to the Urgent queue and bypass normal message processing. Keep HandleInspect implementation fast - don't do expensive computations or I/O. Return only string values (serialization limitation). The optional item parameters allow filtering which fields to return, though most implementations ignore them and return all fields.
What you choose to expose here decides what can be diagnosed later, and everything built on top of it - the observer view, cluster-wide diagnostics, an AI agent correlating state with your source - is bounded by that choice. See Inspecting Actor State.
For workload distribution, use act.Pool instead of implementing manual worker management. See Pool for details.
Don't spawn goroutines in callbacks. The actor model is sequential - one message at a time. Spawning goroutines breaks this, introducing data races on actor state. If you need concurrency, spawn child actors and send them messages.
Don't block on channels or mutexes. Callbacks run in the actor's goroutine. Blocking it starves message processing. Use async message passing (Send) instead of sync primitives.
Don't store gen.Process references. The embedded act.Actor provides all process methods. Storing additional references wastes memory and can cause confusion about which instance is authoritative.
Return errors for termination, not for caller responses. HandleCall's error return terminates the process. To send errors to callers, return them as the result value.
Use ref.IsAlive() before expensive async work. When handling calls asynchronously, check if the caller is still waiting before spending resources on the response.
Enable TrapExit only when needed. Default behavior (terminate on exit signal) works for most actors. Trap only when you have specific failure handling logic.
func (w *DBWorker) Init(args ...any) error {
radar.RegisterService(w, "postgres",
radar.ProbeLiveness|radar.ProbeReadiness, 10*time.Second)
w.scheduleHeartbeat()
return nil
}
func (w *DBWorker) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageHeartbeat:
radar.Heartbeat(w, "postgres")
w.scheduleHeartbeat()
}
return nil
}
func (w *DBWorker) scheduleHeartbeat() {
w.cancelHeartbeat, _ = w.SendAfter(w.PID(), messageHeartbeat{}, 3*time.Second)
}case CacheConnectionLost:
radar.ServiceDown(w, "cache")
case CacheConnectionRestored:
radar.ServiceUp(w, "cache")radar.RegisterService(process, signal, probe, timeout) // sync Call
radar.UnregisterService(process, signal) // sync Call
radar.Heartbeat(process, signal) // async Send
radar.ServiceUp(process, signal) // async Send
radar.ServiceDown(process, signal) // async Sendfunc (w *APIHandler) Init(args ...any) error {
radar.RegisterGauge(w, "active_connections",
"Number of active client connections", []string{"protocol"})
radar.RegisterCounter(w, "requests_total",
"Total HTTP requests processed", []string{"method", "status"})
radar.RegisterHistogram(w, "request_duration_seconds",
"Request latency distribution", []string{"method"},
[]float64{0.01, 0.05, 0.1, 0.5, 1.0, 5.0})
return nil
}func (w *APIHandler) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case RequestCompleted:
radar.CounterAdd(w, "requests_total", 1,
[]string{msg.Method, msg.StatusCode})
radar.HistogramObserve(w, "request_duration_seconds",
msg.Duration.Seconds(), []string{msg.Method})
case ConnectionChange:
radar.GaugeSet(w, "active_connections",
float64(msg.Count), []string{msg.Protocol})
}
return nil
}// Registration (sync Call, returns error)
radar.RegisterGauge(process, name, help, labels)
radar.RegisterCounter(process, name, help, labels)
radar.RegisterHistogram(process, name, help, labels, buckets)
radar.UnregisterMetric(process, name)
// Updates (async Send, fire-and-forget)
radar.GaugeSet(process, name, value, labels)
radar.GaugeAdd(process, name, value, labels)
radar.CounterAdd(process, name, value, labels)
radar.HistogramObserve(process, name, value, labels)func (w *QueryTracker) Init(args ...any) error {
// Keep the 10 slowest queries each cycle
radar.RegisterTopN(w, "slowest_queries", "Slowest DB queries",
10, radar.TopNMax, []string{"query", "table"})
return nil
}
func (w *QueryTracker) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case queryCompleted:
radar.TopNObserve(w, "slowest_queries", msg.Duration.Seconds(),
[]string{msg.SQL, msg.Table})
}
return nil
}// Registration (sync Call, returns error)
radar.RegisterTopN(process, name, help, topN, order, labels)
// Observation (async Send, fire-and-forget)
radar.TopNObserve(process, name, value, labels)func (w *DBPool) Init(args ...any) error {
// Health: liveness + readiness with heartbeat
radar.RegisterService(w, "db_pool",
radar.ProbeLiveness|radar.ProbeReadiness, 10*time.Second)
// Metrics: connection pool gauge
radar.RegisterGauge(w, "db_pool_connections",
"Database connection pool size", []string{"state"})
w.scheduleCheck()
return nil
}
func (w *DBPool) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageCheck:
if w.pool.Ping() == nil {
radar.Heartbeat(w, "db_pool")
}
radar.GaugeSet(w, "db_pool_connections",
float64(w.pool.ActiveCount()), []string{"active"})
radar.GaugeSet(w, "db_pool_connections",
float64(w.pool.IdleCount()), []string{"idle"})
w.scheduleCheck()
}
return nil
}func (w *Migrator) Init(args ...any) error {
radar.RegisterService(w, "migrations", radar.ProbeStartup, 0)
radar.RegisterGauge(w, "migrations_pending",
"Number of pending migrations", nil)
w.Send(w.PID(), messageRunMigrations{})
return nil
}
func (w *Migrator) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageRunMigrations:
pending := w.countPending()
radar.GaugeSet(w, "migrations_pending", float64(pending), nil)
if err := w.runNext(); err != nil {
return err
}
if w.countPending() > 0 {
w.Send(w.PID(), messageRunMigrations{})
return nil
}
// All done. Mark startup complete.
radar.GaugeSet(w, "migrations_pending", 0, nil)
radar.ServiceUp(w, "migrations")
radar.UnregisterService(w, "migrations")
}
return nil
}apiVersion: v1
kind: Pod
spec:
containers:
- name: myapp
livenessProbe:
httpGet:
path: /health/live
port: 9090
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 9090
periodSeconds: 10
startupProbe:
httpGet:
path: /health/startup
port: 9090
failureThreshold: 30
periodSeconds: 2scrape_configs:
- job_name: 'ergo'
static_configs:
- targets: ['localhost:9090']
scrape_interval: 15stype Worker struct {
act.Actor
counter int
}
func (w *Worker) Init(args ...any) error {
w.counter = 0
w.Log().Info("worker %s starting", w.PID())
return nil
}
func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case IncrementRequest:
w.counter += msg.Amount
w.Send(from, IncrementResponse{Counter: w.counter})
}
return nil
}
func (w *Worker) Terminate(reason error) {
w.Log().Info("worker stopped: %s", reason)
}
// Factory function for spawning
func createWorker() gen.ProcessBehavior {
return &Worker{}
}pid, err := node.Spawn(createWorker, gen.ProcessOptions{})type ActorBehavior interface {
gen.ProcessBehavior
// Core lifecycle
Init(args ...any) error
HandleMessage(from gen.PID, message any) error
HandleCall(from gen.PID, ref gen.Ref, request any) (any, error)
Terminate(reason error)
// Split handle callbacks (opt-in via SetSplitHandle)
HandleMessageName(name gen.Atom, from gen.PID, message any) error
HandleMessageAlias(alias gen.Alias, from gen.PID, message any) error
HandleCallName(name gen.Atom, from gen.PID, ref gen.Ref, request any) (any, error)
HandleCallAlias(alias gen.Alias, from gen.PID, ref gen.Ref, request any) (any, error)
// Specialized callbacks
HandleLog(message gen.MessageLog) error
HandleEvent(message gen.MessageEvent) error
HandleSpan(message gen.TracingSpan) error
HandleInspect(from gen.PID, item ...string) map[string]string
}pid, err := node.Spawn(createWorker, gen.ProcessOptions{}, "config", 42)
// In your actor:
func (w *Worker) Init(args ...any) error {
if len(args) > 0 {
w.config = args[0].(string)
}
if len(args) > 1 {
w.maxCount = args[1].(int)
}
return nil
}func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case WorkRequest:
result := w.process(msg)
w.Send(from, WorkResponse{Result: result})
case StatusQuery:
w.Send(from, StatusResponse{Status: w.status})
case StopCommand:
return gen.TerminateReasonNormal // Terminate gracefully
}
return nil // Continue running
}func (w *Worker) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
switch req := request.(type) {
case GetCounterRequest:
return CounterResponse{Counter: w.counter}, nil
case ResetCounterRequest:
old := w.counter
w.counter = 0
return ResetResponse{OldValue: old}, nil
default:
w.Log().Warning("unknown request type: %T from %s", request, from)
return nil, nil // Don't respond to unknown requests
}
}func (w *Worker) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
switch req := request.(type) {
case DivideRequest:
if req.Divisor == 0 {
return fmt.Errorf("division by zero"), nil
}
return req.Dividend / req.Divisor, nil
}
w.Log().Warning("unknown request type: %T from %s", request, from)
return nil, nil
}
// Caller side:
result, err := process.Call(workerPID, DivideRequest{10, 0})
if err != nil {
// Framework error (timeout, process unknown, etc.)
log.Printf("call failed: %s", err)
return
}
if e, ok := result.(error); ok {
// Application error returned by HandleCall
log.Printf("operation failed: %s", e)
return
}
// Success - use result
log.Printf("result: %v", result)func (w *Worker) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
switch req := request.(type) {
case ExpensiveQuery:
// Send to worker pool
w.Send(w.workerPool, PoolRequest{
Query: req,
Caller: from,
Ref: ref,
})
// Return nil, nil to handle asynchronously
return nil, nil
}
return nil, nil
}
// Later, when the worker pool replies:
func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case PoolResponse:
// Send response to original caller
w.SendResponse(msg.Caller, msg.Ref, msg.Result)
}
return nil
}if !ref.IsAlive() {
w.Log().Warning("caller timed out, discarding work")
return nil
}func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case ShutdownCommand:
return gen.TerminateReasonNormal // Clean shutdown
case PanicCommand:
return fmt.Errorf("intentional failure") // Error shutdown
}
return nil
}func (w *Worker) Terminate(reason error) {
w.Log().Info("worker %s stopping: %s", w.PID(), reason)
// Clean up resources
w.closeConnections()
w.sendFinalStats()
}Actor terminated. Panic reason: "index out of range [3] with length 2" at myapp.(*Worker).HandleMessage[worker.go:42]func (w *Worker) Init(args ...any) error {
w.SetTrapExit(true)
return nil
}
func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case gen.MessageExitPID:
w.Log().Info("linked process %s terminated: %s", msg.PID, msg.Reason)
// Decide how to handle it
if msg.Reason == gen.TerminateReasonPanic {
// Linked worker panicked, maybe restart it
w.restartWorker(msg.PID)
}
// Don't terminate - we're trapping
return nil
case gen.MessageExitNode:
w.Log().Warning("node %s disconnected", msg.Name)
// Handle network partition
return nil
}
return nil
}func (w *Worker) Init(args ...any) error {
w.SetSplitHandle(true)
w.RegisterName("worker_service")
alias, _ := w.CreateAlias()
w.publicAPI = alias
return nil
}
func (w *Worker) HandleMessage(from gen.PID, message any) error {
// Messages sent to PID directly (internal use)
w.Log().Debug("internal message from %s", from)
return nil
}
func (w *Worker) HandleMessageName(name gen.Atom, from gen.PID, message any) error {
// Messages sent to registered name "worker_service" (public API)
w.Log().Info("public API call via name %s", name)
return nil
}
func (w *Worker) HandleMessageAlias(alias gen.Alias, from gen.PID, message any) error {
// Messages sent to alias (temporary session)
w.Log().Debug("session message via alias %s", alias)
return nil
}func (w *Worker) HandleLog(message gen.MessageLog) error {
// The text is Format plus Args, not a preformatted string
text := fmt.Sprintf(message.Format, message.Args...)
// Who logged it is in Source, one of four types
switch src := message.Source.(type) {
case gen.MessageLogProcess:
fmt.Printf("[%s] %s: %s\n", message.Level, src.PID, text)
case gen.MessageLogNode:
fmt.Printf("[%s] %s: %s\n", message.Level, src.Node, text)
default:
fmt.Printf("[%s] %s\n", message.Level, text)
}
return nil
}func (w *Worker) HandleEvent(event gen.MessageEvent) error {
switch event.Event.Name {
case "config_updated":
w.reloadConfig()
case "cache_invalidated":
w.clearCache()
}
return nil
}func (w *Worker) HandleInspect(from gen.PID, item ...string) map[string]string {
return map[string]string{
"counter": fmt.Sprintf("%d", w.counter),
"status": w.status,
"queue_depth": fmt.Sprintf("%d", w.queueDepth),
}
}// From within another process
info, err := process.Inspect(workerPID)
// Directly from the node
info, err := node.Inspect(workerPID)Identity
No stable address
Has PID, addressable locally and remotely
State
Shared by default
See Process for details.
Thousands to hundreds of thousands. Processes sleep when idle and consume no CPU. Memory footprint per process is minimal, comparable to a goroutine plus a small mailbox struct.
Yes. Ergo supports both async (Send) and sync (Call) patterns. Call blocks the calling process until a response arrives or a timeout occurs, while maintaining full actor model guarantees. See Handling Sync Requests.
Its supervisor detects the failure and applies a restart strategy:
One-For-One: restart only the failed child
All-For-One: restart all children when one fails
Rest-For-One: restart the failed child and all children started after it
Simple-One-For-One: identical children spawned dynamically at runtime, restart failed ones
Supervision trees are hierarchical. A failed subtree is isolated and recovered without affecting the rest of the system. See Supervision Tree and Supervisor.
No. Supervision handles process recovery automatically. For message delivery, use the Important Delivery flag: the sender learns that the target was unknown, terminated or full instead of guessing from a timeout. Same node, that error comes back synchronously; across nodes it is the remote's own routing error, forwarded after the remote enqueued or refused the message - which needs EnableImportantDelivery in the network flags of both nodes.
Everyone who was watching something over that connection is told, and the message matches what they were watching, not the fact that a node went away. A monitor on a remote process yields MessageDownPID or MessageDownProcessID with a reason; a link yields the corresponding exit signal. MessageDownNode and MessageExitNode arrive only for those who monitored or linked the node itself through MonitorNode / LinkNode, and they carry a name and no reason. Your actors handle the notification and decide how to respond: retry, failover, or graceful degradation. See Links and Monitors.
Through a registrar. Each node runs a minimal built-in registrar by default. Nodes on the same host discover each other automatically via localhost. For production clusters across multiple hosts, configure an external registrar:
etcd: distributed key-value store, widely used
Saturn: Ergo's own central registrar, purpose-built for Ergo clusters
See Service Discovering.
No. Ergo eliminates the integration tax of traditional microservice architectures. No HTTP or gRPC endpoints to define between services, no sidecar proxies, no API gateways for internal routing. Process-to-process communication is direct through the framework's network layer.
Ergo does support Kubernetes for deployment. The Health actor provides liveness, readiness, and startup health probes, and the Metrics actor provides Prometheus metrics on a single port.
The Leader actor uses a Raft-inspired consensus algorithm with majority quorum to prevent split-brain scenarios. When a partition occurs, only the partition with a majority of nodes continues to elect a leader. Minority partitions stop processing leader-dependent operations until connectivity is restored.
Yes. ergo.cloud is a managed overlay network that connects Ergo nodes across AWS, GCP, Azure, and bare metal into one transparent cluster without VPNs, proxies, or tunnels. End-to-end encrypted. Currently available via waitlist.
A producer process registers a named event. Any process on any node subscribes using LinkEvent or MonitorEvent. The framework delivers messages to all subscribers transparently across the cluster.
See Events.
The framework uses fan-out at the consumer node level, not per subscriber. One network message is sent per remote node regardless of how many subscribers that node has. Local delivery then fans out within the node.
Result: 2.9M messages/second delivery rate to 1,000,000 subscribers across 10 nodes using only 10 network messages, not 1,000,000. See Pub/Sub Internals.
All three are relations held by the same target manager inside the node, which is why they behave alike and why a link, a monitor and an event subscription are all torn down the same way. All three are unidirectional: the notification flows from the target to the watcher, not the other way around. Note this differs from Erlang, where links are bidirectional.
Link: when the target terminates, the watcher receives an exit signal on its Urgent queue. The default behavior is to terminate the watcher. Actors can enable exit trapping to receive the signal as a gen.MessageExit* message and decide how to react.
Monitor: when the target terminates, the watcher receives a gen.MessageDown* notification on its System queue. The watcher continues running.
Event: the watcher subscribes to a named stream of messages published by a producer. The producer terminating also delivers a notification (exit signal for link-based subscriptions, down message for monitor-based).
See Links and Monitors and Pub/Sub Internals.
21M+ messages/second locally on a 64-core processor
~5.5M messages/second over the network
EDF serialization: up to 47% faster encoding than Protobuf, 6 to 14 times faster than Gob
Distributed Pub/Sub: 2.9M msg/sec to 1M subscribers across 10 nodes
Full benchmarks: benchmarks repository.
Ergo uses EDF (Ergo Data Format) with type caching. The two sides exchange their registered type lists during the handshake and agree on a compact id per type, so a message carries a two-byte reference instead of a type name - from the first message onward, not after a warm-up. A type registered later than the handshake still works: it falls back to sending its full canonical name until the next handshake, and that is the only case where type information travels with each message. Together with no reflection on the hot path, this makes EDF significantly faster than Protobuf for encoding and decoding in high-throughput scenarios.
Yes. Ergo has native distributed tracing that follows message chains across processes and nodes. When a traced process sends a message, the trace identity travels with the message and propagates automatically through the entire downstream chain of handlers. You configure tracing on entry-point processes. Downstream actors need no instrumentation.
Traces can be viewed directly in Observer as waterfall diagrams or exported to OTLP-compatible backends (Grafana Tempo, Jaeger, OpenTelemetry Collector) via the Pulse application. See Distributed Tracing for details.
Run the Observer web UI for live visibility into processes, applications, network connections, events, logs, tracing waterfalls, and heap profiles. The same application serves an MCP surface, which exposes the running system to Claude Code, Cursor, or any MCP-compatible client for AI-driven investigation. For continuous metrics, the Radar application provides a Prometheus endpoint with a ready-to-use Grafana dashboard.
Observer is a live dashboard for a running Ergo system. You add it with one line of code and open http://localhost:9911 in a browser. It shows you, updating every second, what your program is actually doing inside, and it lets you act on it without stopping it or adding any code.
An Ergo program is built from many small processes that each do one job and talk to each other by sending messages. Observer lets you:
See the whole node at a glance: how much memory and CPU it uses, how many processes are running, how busy they are.
Find the part that is misbehaving: sort and filter the process list to spot the one that is overloaded, stuck, or using the most time, even among tens of thousands of them, or color an application's process tree so the hot branch stands out.
Look inside a single process: its message rates, what it is connected to, and any internal state it chooses to report.
Watch things happen in real time: a filterable live log stream, the actual messages a producer is publishing as they go out, and a single request traced step by step as it travels from process to process and across machines.
Track down memory leaks and freezes: live memory and goroutine views, including flame graphs, with no special build and no restart.
And it is not just for looking. From the same screen you can change a process's log level, send it a message, restart or stop it, start and stop parts of the application, and switch tracing on, all on the live system.
One Observer covers the whole cluster. Every Ergo node exposes itself to Observer automatically, so you run it on one node and move between any of them from the sidebar, reaching each through service discovery or by address.
See Observer Application to add it to your node, and Inspecting With Observer for a guided tour.
Ergo ships a dedicated testing harness. An actor is not a function you can call and inspect, so the harness observes it the way the rest of the system does: through what it does. Every outward action (a message sent, a process spawned, a log line written, an exit signal, a timer scheduled) is captured as a record. You drive the actor with inputs and assert on the records it produced, testing behavior rather than reaching into private state.
The harness has four layers that share one fluent assertion grammar:
unit
One actor in-process against a mock node. Synchronous, deterministic, fast. Where most actor logic is tested.
stage
Real nodes running real actors over the real network, including multi-node clusters. For the scheduler, supervision, restarts, links and monitors across nodes, remote spawn, and disconnects.
mock
See Testing Overview.
Yes. Ergo supports the full Erlang network stack: EPMD, ETF (External Term Format), and DIST protocol. You can build hybrid Go/Erlang clusters where Ergo nodes and BEAM nodes coexist and communicate natively. See Erlang protocol.
Yes. The Metrics actor exports node and network telemetry via a Prometheus HTTP endpoint. A ready-to-use Grafana dashboard is provided via Radar.
Yes, via Meta Processes. Each WebSocket or SSE connection becomes an independent meta-process with a stable identifier (gen.Alias). Any actor anywhere in the cluster can send messages directly to a specific client connection. No routing intermediaries needed. This enables real-time push from any cluster node to any specific connected client.
Yes. Ergo's Web meta-process integrates with standard net/http. You use any Go router (stdlib ServeMux, gorilla/mux, chi, echo) and any HTTP middleware. Actors are an implementation detail invisible to the HTTP layer.
Yes, and it is particularly well-suited. Each AI agent runs as an isolated process with a mailbox. No shared state between agents, no race conditions. Supervisor trees restart stuck or crashed agents automatically. Multiple agents coordinate through message passing. Agents distribute transparently across cluster nodes as load grows. See AI Agents for patterns and diagnostics.
Ergo has built-in support for the Model Context Protocol (MCP), an emerging standard for AI tool integration. The Observer application serves it beside its web UI: the running cluster reaches AI assistants (Claude Code, Cursor, and any MCP-compatible client) as resources to read and tools to call. The AI inspects processes, queries events, captures goroutine dumps, reads logs, and asks the same question of every node, through natural language.
There is one deployment shape, and only one node needs anything installed. Add the observer to a single node - that node serves /mcp and is what your AI client connects to. Every other node is reachable through it as it is, because each already runs the built-in system application the observer asks: a resource is addressed as ergo://<node>/<lens>, and every tool takes a node argument. Nothing has to be opened on the nodes being inspected, and the observer's own node holds no privileged position - name it explicitly like any other.
What a listener exposes is configurable per listener: the UI, the API and the MCP surface can each be disabled, and a read-only ceiling refuses the mutating tools. See Observer Application.
See ergo tool documentation for the full command reference.
Commercial support: support@ergo.services
// Producer
token, _ := producer.RegisterEvent("market.prices", gen.EventOptions{})
producer.SendEvent("market.prices", token, PriceUpdate{Asset: "BTC", Price: 95000})
// Subscriber on any node
process.MonitorEvent(gen.Event{Name: "market.prices", Node: "producer@host"})
// Event messages arrive in HandleEvent
func (s *Sub) HandleEvent(event gen.MessageEvent) error {
update := event.Message.(PriceUpdate)
// handle update
return nil
}
// Producer termination or event unregister arrives in HandleMessage as MessageDownEvent
func (s *Sub) HandleMessage(from gen.PID, msg any) error {
switch msg.(type) {
case gen.MessageDownEvent:
// producer terminated or unregistered
}
return nil
}sub, _ := unit.Spawn(t, factoryWorker, gen.ProcessOptions{})
sub.SendMessage(client, StartJob{ID: "42"})
sub.ShouldSend().To(gen.Atom("scheduler")).Message(JobQueued{ID: "42"}).Once().Assert()# Install the project generator
go install ergo.tools/ergo@latest
# Create a project
ergo init MyNode github.com/myorg/mynode
cd mynode
# Add components
ergo add supervisor MyNodeApp:MySup
ergo add actor MySup:MyWorker
# Run
go run ./cmdGuaranteed message delivery with acknowledgment
In the actor model, messages are typically fire-and-forget. You send a message, and it either arrives or it doesn't. For local communication, errors are immediate - if the process doesn't exist or the mailbox is full, Send returns an error. But for remote communication, Send succeeds as soon as the message reaches the network layer. You don't know if it arrived at the remote node, if the target process exists, or if the mailbox had space.
This works fine for many scenarios. Asynchronous messaging doesn't require confirmation. Actors process what arrives and ignore what doesn't. Systems are resilient because actors don't wait for acknowledgments - they keep working.
But some operations need certainty. A payment authorization must definitely be recorded or definitely fail - "maybe it worked" isn't acceptable. A distributed transaction coordinator needs to know that all participants received the commit message before proceeding. Critical state updates can't be silently lost.
Important Delivery provides guaranteed message delivery through acknowledgment. When you send with the important flag, the framework tracks the message, waits for confirmation from the recipient, and reports errors if delivery fails.
Without important delivery, remote communication is opaque:
The remote Send succeeds even if:
The remote process doesn't exist
The remote process's mailbox is full
The remote node received the message but dropped it
The network delivered the message but it got lost before reaching the process
You only discover problems through absence - no response arrives, timeouts fire, but you don't know why. Did the request get lost? Did the process crash? Is it just slow?
Important delivery makes remote communication transparent - errors are immediate, just like local:
The framework sends the message, waits for acknowledgment from the remote node, and reports the outcome. Either the message is in the recipient's mailbox (success) or you get an error explaining what went wrong (failure). No ambiguity.
There are two ways to enable important delivery:
Method 1: Per-message explicit methods
Use SendImportant and CallImportant instead of Send and Call:
Method 2: Process-level flag
Set the important delivery flag on the process - all outgoing messages use important delivery:
The process-level flag affects all outgoing messages: Send, SendPID, SendProcessID, SendAlias, and Call requests. You don't need to use special methods - regular Send and Call automatically include the important flag.
Use the flag when the process primarily deals with critical messages. Use explicit methods when only specific messages require guarantees.
Here's what happens when you send a message with important delivery:
The sender blocks until the acknowledgment arrives. The remote node attempts delivery and sends either success (ACK) or failure (error). The sender's SendImportant unblocks with the result.
For local sends, the behavior is identical to regular Send - immediate error if the process doesn't exist or mailbox is full. The important flag only affects remote sends.
Call requests already have a response channel (the caller waits for HandleCall to return), so important delivery works differently. The ACK is only sent if there's an error - if delivery succeeds, no ACK is sent, and the caller waits for the actual response:
The key difference from regular Call: with CallImportant, if the remote process doesn't exist or its mailbox is full, you get an immediate error instead of waiting for timeout. If delivery succeeds, you wait for the response just like regular Call.
Without the important flag, ErrProcessUnknown looks like timeout - you can't tell if the process is slow, dead, or never existed. With important delivery, you know immediately.
Things get interesting when you combine important delivery on requests with important delivery on responses. There are four combinations, each with different guarantees.
Guarantees: None. Request may be lost. Response may be lost. Timeout is ambiguous.
Use case: Fast, non-critical operations where occasional loss is acceptable.
Guarantees: Response delivery is confirmed. If the handler returns a result, the caller will receive it (or get an error if delivery fails). Request delivery is not confirmed - the handler might never receive the request.
Protocol name: RR-2PC (Response-Reliable Two-Phase Commit)
Use case: The handler's work is critical, the caller must know if it succeeded. Example: committing a transaction. If the transaction commits, the caller must know. But it's okay if the request gets lost (request is idempotent, can be retried).
How it works:
The handler blocks after processing until the caller acknowledges the response. If the caller crashes before sending ACK, the handler's SendResponseImportant returns ErrResponseIgnored or ErrTimeout.
The request has no guarantee - it might be lost, and the caller would timeout. But if the handler processed the request and sends a response, that response is guaranteed to be delivered.
Guarantees: Request delivery is confirmed. The handler will receive the request (or caller gets an error immediately). Response delivery is not confirmed - response may be lost.
Use case: The handler must receive the request, but the response is less critical or can be retried. Example: triggering a background job. The job must start, but if the status response is lost, the caller can query status later.
How it works:
The caller gets immediate confirmation that the request arrived, then waits for the response. If the response gets lost, the caller times out - but knows the handler received and processed the request.
Guarantees: Both request and response delivery are confirmed. The handler definitely receives the request, and the caller definitely receives the response. No ambiguity at any point.
Protocol name: FR-2PC (Fully-Reliable Two-Phase Commit)
Use case: Critical operations where both request and response must be guaranteed. Example: distributed transaction commit coordination, financial operations, critical state synchronization.
How it works:
With FR-2PC:
The caller gets immediate error if request can't be delivered (no ambiguous timeout)
If request is delivered, caller waits for response
The handler blocks after sending response until caller confirms receipt
Both sides know definitively whether delivery succeeded
This is the most reliable pattern but also the most expensive. Use it only when guaranteed delivery is essential.
FR-2PC provides the messaging reliability needed to implement Three-Phase Commit (3PC) and other distributed transaction protocols at the application level.
Traditional Two-Phase Commit (2PC) has a blocking problem: if the coordinator crashes after participants vote "yes" but before sending commit/abort, participants don't know what to do. They're stuck.
Three-Phase Commit solves this by adding a pre-commit phase:
Prepare: Can you commit?
Pre-commit: Everyone said yes, get ready to commit
Commit: Now commit
If the coordinator crashes after pre-commit, participants know the outcome was "commit" and can proceed independently.
But 3PC only works if messages are reliably delivered. If a pre-commit message gets lost and a participant doesn't receive it, the protocol breaks - some participants think we're committing, others are still waiting.
FR-2PC guarantees that messages are delivered or errors are reported. This lets you implement 3PC confidently:
FR-2PC ensures that:
If CallImportant returns nil, the participant received the message
If CallImportant returns an error, the participant didn't receive the message
No ambiguous timeouts where you don't know if the message arrived
This determinism is essential for 3PC. Without it, you'd need complex timeout-based recovery that can't distinguish "participant is slow" from "participant is dead" from "message was lost."
Important delivery adds overhead:
Extra round trip: Sender waits for ACK before proceeding
Sender blocks: Can't process other messages while waiting
Network traffic: Additional ACK messages
For SendImportant, the sender blocks until ACK arrives (success or error) or timeout. For CallImportant, the sender gets immediate error if delivery fails, or waits for response if delivery succeeds (no extra ACK on success).
The blocking is process-local - only the sending actor waits. Other actors on the node continue normally. But the sending actor's mailbox isn't processed during the wait.
Use important delivery selectively:
Use for: Critical state updates, transaction coordination, payment processing, data synchronization
Don't use for: High-frequency updates, informational messages, monitoring events, retryable operations
Most actor communication doesn't need guarantees. The actor model is resilient because actors handle partial failure gracefully. Important delivery is for the cases where partial failure isn't acceptable - where certainty is worth the cost.
Important delivery only affects remote communication. For local sends:
Local mailbox operations are synchronous - pushing to the mailbox either succeeds or fails immediately. The important flag is unnecessary because there's no network uncertainty. The framework silently treats local important sends as regular sends.
This means your code works identically for local and remote processes. You can use SendImportant everywhere without checking if the target is local or remote - the framework optimizes local communication automatically.
Important delivery produces specific errors:
ErrProcessUnknown - The remote process doesn't exist. Without important delivery, you'd discover this through timeout. With important delivery, you know immediately.
ErrProcessMailboxFull - The remote process exists but its mailbox is full. Without important delivery, the message would queue in the network layer or be dropped. With important delivery, you get immediate feedback.
ErrTimeout - The remote node received the message but didn't send ACK within the timeout period. This is different from Call timeout - it means the node is unresponsive or overloaded.
ErrResponseIgnored - For important responses, the caller is no longer waiting (timed out or terminated). The response couldn't be delivered. Without important delivery, the handler wouldn't know the response was ignored.
ErrNoConnection - Cannot establish connection to the remote node. This error occurs for both regular and important sends, but important delivery surfaces it immediately instead of silently queueing.
These specific errors let you handle different failure modes appropriately - retry for ErrTimeout, provision more resources for ErrProcessMailboxFull, fail immediately for ErrProcessUnknown.
Important delivery trades performance for certainty. Messages are guaranteed to be delivered or errors are reported immediately. Use it when:
The operation is critical and must succeed or definitely fail
Ambiguous timeouts are unacceptable
You're implementing distributed protocols that require guaranteed delivery
The cost of retrying without knowing if the first attempt succeeded is high
For most actor communication, fire-and-forget messaging is sufficient. The actor model handles uncertainty through supervision, retries, and eventual consistency. Important delivery is for the cases where uncertainty itself is the problem.
For more on handling synchronous requests, see .
A meta-process solves a specific problem: how to integrate blocking I/O with the actor model without breaking its guarantees. It runs two goroutines - one executes your blocking I/O code, the other handles actor messages. This separation preserves sequential message processing while allowing continuous external I/O operations.
Meta-processes are owned by their parent process. When the parent terminates, all its meta-processes terminate with it. This dependency is by design - meta-processes extend the parent's capabilities rather than existing as independent entities in the supervision tree.
Actors work sequentially. One message arrives, gets processed, completes. Next message. This simplicity eliminates race conditions and makes reasoning straightforward.
Blocking I/O breaks this model. Call net.Listener.Accept() in a message handler and the actor freezes. The goroutine blocks waiting for connections. Other messages pile up unprocessed. The actor becomes unresponsive.
The obvious fix fails. Spawn a goroutine for Accept() and now two goroutines access the actor's state concurrently. You need locks. The sequential guarantee vanishes. The actor model collapses into traditional concurrent programming with all its complexity.
Meta-processes preserve both. One goroutine blocks on I/O. Another goroutine processes messages sequentially. Neither interferes with the other.
When a meta-process starts, the framework launches two goroutines:
External Reader: Runs your Start() method from beginning to end. This goroutine is meant for blocking operations - Accept() loops, ReadFrom() calls, reading from pipes. When external events occur, this goroutine sends messages into the actor system using Send(). It never processes incoming messages.
Actor Handler: Created on-demand when messages arrive in the mailbox. Processes messages sequentially by calling your HandleMessage() and HandleCall() methods. When the mailbox empties, this goroutine terminates. Next time messages arrive, a new actor handler spawns. This goroutine never does I/O directly - it handles requests from actors.
The External Reader runs continuously from spawn until termination. The Actor Handler comes and goes based on message traffic.
Processes have one goroutine that must handle everything. If it blocks on I/O, message processing stops. If it spawns additional goroutines for I/O, the actor model breaks.
Meta-processes separate concerns. The External Reader handles I/O. The Actor Handler handles messages. Both run independently.
Meta-processes cannot make synchronous calls. Which goroutine should block waiting for the response? The External Reader is blocked on external I/O. The Actor Handler might not be running. Neither can reliably wait for responses.
Meta-processes cannot create links or monitors. When a linked process terminates, it sends an exit signal as a message. The Actor Handler processes messages, but only when running. Signals could be delayed or lost if the Actor Handler is not active. Incoming links and monitors work because other processes send signals that queue in the mailbox. Creating outgoing links requires guarantees that meta-processes cannot provide.
These are not arbitrary limitations. They follow from having two goroutines with distinct responsibilities.
Init() runs once during creation. Initialize state, store the MetaProcess reference, prepare resources. Return an error to prevent spawning.
Start() runs in the External Reader. This is where your blocking I/O lives. Loop forever accepting connections. Block reading datagrams. Read from pipes. When Start() returns, the meta-process terminates.
HandleMessage() processes regular messages sent by actors. Runs in the Actor Handler. Return nil to continue, return an error to terminate.
HandleCall() processes synchronous requests from actors. Return (result, nil) and the result is sent back.
The second value is a termination reason, not an error to hand to the caller. Any non-nil reason other than gen.TerminateReasonNormal sends no response at all: the meta terminates, its alias is deleted, and the caller sits until its own timeout expires. To report a failure, return the error as the result, the way the stock metas do - return gen.ErrUnsupported, nil - so the caller receives it as the response value.
Terminate() runs during shutdown regardless of how termination occurred. Close resources, flush buffers, clean up. Do not block or panic here.
HandleInspect() returns diagnostic information as string key-value pairs. Used by monitoring tools. Inspect requests are sent to the system queue (high priority) and processed before regular messages. You can inspect meta processes from within a process context using process.InspectMeta(alias) or directly from the node using node.InspectMeta(alias). Both methods only work for local meta processes (same node).
Sleep: External Reader is running (usually blocked on I/O), Actor Handler does not exist. Mailbox may contain messages waiting to be processed. This is the resting state when no actors are communicating with the meta-process.
Running: Both goroutines active. External Reader continues I/O operations. Actor Handler processes messages from the mailbox. Both work simultaneously without blocking each other.
Terminated: Both goroutines stopped. Start() returned and Actor Handler completed its final message.
Transitions are automatic. Message arrives → Actor Handler spawns → Sleep becomes Running. Mailbox empties → Actor Handler exits → Running becomes Sleep. Start() returns → Terminated regardless of current state.
The External Reader blocks reading while the Actor Handler simultaneously blocks writing. Two blocking operations, two goroutines, neither prevents the other.
Define your behavior:
Spawn from a process:
The meta-process lives as long as its parent lives. When Server terminates, the UDP server terminates automatically.
Different operations are available in different states:
All states (Sleep, Running, Terminated):
Send(), SendWithPriority() - External Reader sends in Sleep, Actor Handler sends in Running
ID(), Parent() - Identity never changes
Env()
Running only:
SendResponse(), SendResponseError() - Only Actor Handler has the gen.Ref from HandleCall()
SetSendPriority(), SetCompression() - Actor Handler controls these
Sleep and Running (not Terminated):
Spawn() - Both goroutines can spawn child meta-processes
SendAfter(), SendEvery() - Timers are armed from Init() and from the External Reader
The External Reader operates in Sleep state and has minimal capabilities - just sending messages and spawning children. The Actor Handler operates in Running state and has full capabilities for processing requests.
A meta-process often needs a heartbeat, a flush on an interval, or a deadline for a handshake that never completed. The tempting fix inside Start() is a time.Ticker in one more goroutine, and it is wrong for the same reason it is wrong in a process: that goroutine belongs to neither the External Reader nor the Actor Handler, so whatever it touches escapes the two-goroutine discipline the meta-process exists to preserve.
Let the meta-process message instead. SendAfter schedules one message after a delay, SendEvery repeats it on a period reusing a single timer, and both deliver through a mailbox. Addressed to Parent() the tick lands in the parent actor's HandleMessage; addressed to ID() it wakes the meta-process's own Actor Handler, one message at a time, like any other message.
Both are available while the meta-process is asleep, which is what makes them usable at all: Init() and the External Reader arm them without being inside a callback.
Keeping the returned cancel function is optional for shutdown. A scheduled message is dropped once the meta-process or its parent terminates, so a heartbeat stops on its own when the connection behind it closes and Terminate() has nothing to unwind. Cancel explicitly when a schedule should end while the meta-process keeps running: the handshake beat its own deadline, the resource being polled went away. The cancel function reports whether it actually cancelled anything, so a one-shot that already fired answers false.
Targets are the ones Send() accepts: a PID, a gen.ProcessID, an alias, or a registered name as a gen.Atom. A period of zero or less is rejected instead of becoming a busy loop.
Both goroutines access the same struct fields. Use atomic operations for shared counters and flags:
Avoid complex synchronization. If you need mutexes, the design probably belongs in a regular process with meta-processes handling only I/O.
External events to actors: External Reader reads events, sends them to actors for processing.
Actor-controlled I/O: Actors send commands, Actor Handler executes them against external resources.
Full-duplex communication: External Reader reads, Actor Handler writes, both operate on the same connection.
Server accepting connections: External Reader accepts connections, spawns child meta-processes for each.
Use meta-processes when:
Operating on blocking I/O (TCP accept, UDP read, pipe read, file read)
Bridging external event sources with actors (monitoring filesystems, listening to OS signals)
Wrapping synchronous APIs that cannot be made asynchronous
Implementing network servers where accept loop must run continuously
Do not use meta-processes when:
Implementing business logic
Managing application state
Coordinating between actors
Processing messages that do not involve blocking I/O
Meta-processes sit at the boundary between the external world and the actor system. They translate blocking operations into asynchronous messages and execute actor commands using blocking APIs. Regular processes implement everything else.
For complete examples, see , , , and .
Distributed leader election for coordinating work across a cluster
The leader actor elects exactly one coordinator among a group of nodes and tells your code when it becomes that coordinator and when it stops being one. You embed it, implement two callbacks, and the work that must run in exactly one place starts and stops on its own.
Discovery is not part of it. Resolve peers however suits your deployment - a registrar lookup, static configuration, a message from another system - and hand the names to Join. The actor negotiates with them from there, and membership spreads through the protocol once one side knows the other.
Plenty of work must happen once, not once per node. A scheduler that fires cron jobs. A reconciler that scans a database and dispatches what it finds. A single writer to a resource that cannot take concurrent writers. Run it on every node and you get duplicate jobs, duplicate dispatches, duplicate writes. Run it on one designated node and you have a single point of failure that needs a human to move.
The obvious shortcut is to pick deterministically: sort the node names, lowest one wins. It needs no messages and it is genuinely appealing, right up to the moment two nodes disagree about the list. Node A believes the set is {A, B, C} and picks A; node B has just begun suspecting A and believes the set is {B, C}
Private by discipline: nothing else reaches it unless you send a reference
Failure recovery
Manual
Automatic via supervision
Cross-node messaging
Not built in
Same API, transparent
Race conditions
Possible
None inside one actor - it handles one message at a time
Standalone fakes of the gen.* interfaces to inject into ordinary non-actor code (helpers, resolvers, constructors).
check
The shared record types and Should... grammar the other layers expose.
Identifier type
gen.PID
gen.Alias
States
Init → Sleep → Running → Terminated
Sleep → Running → Terminated
Spawn children
Processes and meta-processes
Meta-processes only
Synchronous calls
Can make calls with Call()
Cannot make calls
Links and monitors
Can create and receive
Can only receive
EnvList()EnvDefault()Log() - Logging always available
SendPriority(), Compression() - Read settings
Goroutines
One per process
Two per meta-process
Message queues
4 queues (urgent, system, main, log)
2 queues (system, main)
Election is the fix. A node cannot take leadership by deciding it deserves it; it has to be granted by a majority of the group it believes in. Two nodes whose views differ slightly cannot both collect a majority, because their views overlap and a voter grants one vote per term.
The actor is a Raft-style election with no log: terms, votes, heartbeats, and nothing replicated.
Roles. Every actor is in one of four states.
unclustered - the view is smaller than MinClusterSize, so this node may not have a leader at all. Not a failure; a group too small to be a cluster is behaving correctly by not operating as one.
follower - accepting another node's leadership, or waiting to campaign.
candidate - campaigning for a term, waiting for grants.
leader - holding leadership, sending heartbeats.
The view. Membership is the set of peers this node has been told about, plus itself. Peers arrive through Options.Bootstrap at startup, through Join at any time, and through the protocol itself - a node that sends a valid message is a member. A peer counts from the moment it is declared, before it has answered anything.
Terms. A term is a logical clock, not a wall clock. A candidate increments it before asking for votes, and a higher term always wins: any node seeing one adopts it and steps back to follower. This is what settles disagreement without a shared clock.
Quorum. To win, a candidate needs a majority of its view, counting itself. Three nodes need two votes, four need three, five need three. The denominator is the current view, so it moves as membership changes - and MinClusterSize is the floor that stops it moving somewhere useless.
The contract, in three sentences. There is exactly one leader per cluster. A network partition does not put two leaders in one cluster - it splits one cluster into two, each electing its own, and a side too small to meet MinClusterSize or to hold a majority of its own view elects none. When connectivity returns they converge back into one cluster with one leader.
That last point deserves the attention it gets in What It Guarantees below, because it decides what you may safely build on top.
Two steps, and both fail quietly if you skip them: the node starts, nothing errors, and the problem shows up later as a cluster that never converges.
The package registers nothing on import. Type registration is node-scoped, so it cannot happen in a package init(), and doing it inside the actor would be too late - a node whose leader process starts after a connection is already established could not decode a peer's message, and could not repair that after the fact.
The actor checks this at Init and refuses to start if its protocol is not registered: an unregistered vote fails to encode on every send, so the alternative is a healthy-looking node that never joins an election.
Declare them on the application that hosts the actor:
ApplicationSpec.Network is processed during ApplicationLoad, before any process in the application is spawned, which is exactly the timing this needs. Registering on the node directly works too, as long as it happens before the node starts serving:
ErrorTypes() returns nothing today - the actor sends no sentinel errors over the wire. It exists so your setup code stays uniform if that changes.
MinClusterSize is the smallest view - this node plus the peers it knows - that may have a leader at all. The default is 3: the smallest size whose majority, two, survives losing one node.
Lower values are permitted and warned about rather than rejected, because both have legitimate uses and neither breaks the contract:
1 lets a lone node appoint itself. A single-node deployment must set this explicitly, and nothing then prevents a fragment of one from operating alone.
2 gives a quorum of two out of two, so losing either node ends leadership. Sensible only when an external authority gates leadership through HandleConfirmLeader.
Inheriting the default silently is the thing to avoid. Decide the number, write it down.
Two habits in that code worth copying. Join runs from a handler frame rather than from Init, and it re-runs on a timer - discovery is your responsibility, and a one-shot lookup leaves a node that started early with no peers forever. And HandleBecomeFollower stops the work unconditionally; treat it as the only place leadership ends, because it is.
ClusterID is required and namespaces the election. Messages carrying a different one are dropped and counted, so two logically separate groups can share a network without interfering. Give distinct clusters distinct ids; sharing one by accident merges them.
MinClusterSize - see Set MinClusterSize above.
Bootstrap is a static list of peers, and it is exactly Join declared up front: the entries seed the same membership rather than being a second, parallel set of targets. Use it when the peer set is known at deploy time; use Join when it is discovered at runtime; use both if some of it is known and some is not.
ElectionTimeoutMin / ElectionTimeoutMax bound the randomised wait before a follower campaigns. Randomising it is what stops every node timing out at the same instant and splitting the vote. Set only one and the other is derived - min alone doubles to a max, max alone halves to a min - so a partial configuration resolves instead of failing at startup.
HeartbeatInterval is how often a leader asserts itself. It must be comfortably smaller than ElectionTimeoutMin, or followers will time out between heartbeats and campaign against a healthy leader; the actor warns at startup if it is not.
GhostTTL is how long a peer whose connection dropped stays in the view before being dropped. It exists because those two things - a blip and a departure - look identical from here, and neither extreme works: keep an unreachable peer forever and, with dynamic node names, the view fills with names that will never return until quorum exceeds the number of nodes that exist; drop it immediately and a one-second blip lowers quorum. Five seconds separates the cases - far longer than a 150-300 ms election cycle, far shorter than a stage of a rolling deploy.
It is not a safety control; MinClusterSize is. GhostTTL only bounds how long a gone peer keeps inflating quorum, and it is a net under Leave rather than a replacement for it - see Membership. The cost of the window is that quorum is briefly too high, so a node that dies while the cluster sits exactly on its quorum leaves it leaderless for up to that long. That is the reason not to raise it to a minute.
The defaults suit a local network. On a pod-to-pod network across zones, or anywhere a garbage-collection pause can exceed a couple of hundred milliseconds, raise all three together - for example 1000 / 2000 / 300. Aggressive timeouts do not make failover safer, they make spurious elections more likely.
Only Init has no default - embedding leader.Actor satisfies the rest. HandleBecomeLeader and HandleBecomeFollower do have defaults, but they log a warning, because winning leadership and doing nothing with it is almost always a missing implementation rather than an intention.
HandleBecomeLeader is where the singleton work starts. Returning an error rolls the transition back: the actor steps down again and the error terminates it, so use it to refuse leadership you cannot honour.
HandleBecomeFollower is where it stops, and it fires on every transition into follower - not only when a leader is demoted. A follower whose leader disappeared and a candidate that stood down both arrive here, with an empty PID when no leader is known. Make it idempotent.
HandleConfirmLeader is consulted after this node has won the election and before leadership is published. Returning false, or an error, withholds it: HandleBecomeLeader does not run and the node re-campaigns after a backoff that grows with consecutive denials. An error counts as a denial, not as consent - the usual error is a timeout talking to whatever you are asking, which is exactly when someone else may be holding it. See Leadership Safety.
HandlePeerJoined / HandlePeerLeft report membership changes. They are informational; membership is already applied by the time they run.
Discovery belongs to your code. The actor's job is to negotiate with the names you give it.
Join(peer gen.ProcessID) declares a peer and opens negotiation. The peer counts toward the view immediately, before it has answered - which is what lets a node reach MinClusterSize without needing traffic that only a campaigning node produces. Idempotent per node.
Leave(node gen.Atom) withdraws a peer from the view and from every quorum computed afterwards. Call it when your discovery stops listing a node: you know the pod is gone, the actor can only guess. A withdrawal is sticky for one election timeout, so traffic already in flight cannot re-admit what you just removed.
A peer going down is handled by reason. A death reported with an actual reason - the remote process terminated - removes the member immediately. A drop reported as gen.ErrNoConnection does not: the member stays in the view, with only the knowledge of how to reach it forgotten, and is dropped after GhostTTL if it has not come back. Silence alone never shrinks the view inside that window, because shrinking it lowers quorum and would let a momentary blip hand a fragment the ability to elect.
With dynamic node names this matters more than it looks. Where every pod gets a name that will never be reused - $(POD_NAME)@$(POD_IP) and the like - a replaced pod leaves an unreachable member behind, and the framework reports everything on a lost node as ErrNoConnection whatever actually happened to it. Without a bound, two rolling deploys of a five-node cluster leave a view of thirteen and a quorum of seven that the five living nodes can never assemble - a cluster that is permanently leaderless and looks healthy field by field. GhostTTL bounds it, and calling Leave from your discovery loop closes it properly.
Heartbeats go only to peers that have answered. A declared peer that has never replied is not a follower: there is no election timer of yours to suppress, and if it is up it will campaign and be corrected by the reply. Vote requests do go to everyone declared, because reaching out is the whole point of a campaign.
Within a cluster, one leader. A node cannot hold leadership without a majority of its view having granted it for the current term, and a voter grants one vote per term.
A leader that loses contact steps down by itself. On each heartbeat tick it requires evidence of reaching a quorum - an accepted send or any inbound protocol message within one election timeout. Without it, it relinquishes leadership without waiting to be told. This is what prevents an isolated leader from holding on indefinitely, since every other exit from leadership needs an inbound message and an isolated node receives none.
A cluster reconverges after a partition. When the two sides can talk again, the higher term wins and the other side steps down; the view grows back and quorum is recomputed over it.
No leader below the floor. A group smaller than MinClusterSize reports unclustered and elects nobody.
And what it does not guarantee:
Not a fixed number of clusters. The invariant is one leader per cluster, but the number of clusters is not fixed - so from outside, looking at the deployment as one thing, you can see two leaders at once, for as long as the split lasts. Worth being precise about when:
A connectivity partition of a converged cluster does not do this. A lost connection keeps the peer in the view - only what the actor knew about reaching it is forgotten - so the quorum denominator does not shrink. Two disjoint groups cannot both be a majority of the same set, so at most one side elects and the other reports unclustered or waits.
Diverged views do. If each side only ever learned about its own members, each holds a majority of its own smaller view and each elects. That is the cold-start case: nodes come up, discovery resolves only what is reachable, and two groups form without ever having been one.
So does losing members for real. A process that actually died, or a peer you withdrew with Leave, leaves the view and lowers quorum with it.
This is the price of dynamic membership with no seed list: the actor is never told how large the deployment is meant to be, so it cannot tell a group of three from half of six. If you would rather pay in availability than in duplicate leadership, that is what MinClusterSize is for - set it above half the expected node count, and two disjoint groups can never both qualify, at the cost of no leader in any partition smaller than that.
No replicated state. Leadership changes hands; nothing carries state across. If the new leader needs to know what the old one did, put that somewhere both can read.
No persistence. Election state lives in memory. A restarted node rejoins with a fresh term.
Nothing about your external resources. Leadership is scoped to a cluster; a database row, a queue or a lock is not. See below.
Three properties are usually conflated, and only the middle one is the election's job.
Two nodes briefly believing they lead. Unavoidable in an asynchronous system - a superseded leader learns late. Harmless on its own.
Two nodes acting as leader inside one cluster. Prevented, by majority voting and the quorum requirement above.
Two nodes performing an irreversible external action. Not addressed by any election, including this one. When a partition turns one cluster into two, a shared resource is now serving two clusters, each with a legitimate leader, and nothing in the protocol can tell it which to obey.
If leadership authorises something irreversible, the resource has to arbitrate. Two mechanisms, and they compose:
Gate leadership on an external authority. Implement HandleConfirmLeader so that winning the election is necessary but not sufficient - the node must also hold something only one holder can have. A Kubernetes Lease is the natural choice where you already run on Kubernetes: no new infrastructure, and metadata.resourceVersion increases monotonically, so it doubles as a fencing token.
The callback runs on the actor's goroutine. It may talk over the network - it is called once per won election, not on a hot path - but bound it with a timeout well under the election timeout, and prefer answering from state that a separate process keeps up to date. That separate process is also the right place to relinquish leadership when a renewal fails, which the callback alone cannot do because it is only consulted at the transition.
Fence the resource. Carry a monotonically increasing token with every irreversible action and let the resource reject a stale one:
Zero rows updated means a newer holder exists, and the caller must stop. This is the only part of the arrangement that does not depend on timing, and it is what turns "we try to have one writer" into "a superseded writer cannot take effect".
A lock without a monotonic token - a plain SETNX in Redis, for instance - shrinks the window rather than closing it, and cannot survive a failover that promotes a replica which never saw the write. Worth knowing which of the two you have built.
Take a five-node cluster, {A,B,C,D,E}, MinClusterSize: 3, A leading at term 4. The network splits into {A,B,C} and {D,E}.
The majority side. A still reaches B and C: two peers plus itself is three, a majority of its view, so it keeps leadership at term 4. Nothing changes for the work it is running.
The minority side. D and E stop hearing heartbeats and campaign. Their views stay at five - a lost connection does not remove a member, it only forgets how to reach it - so the quorum they need is still three, and between them they can muster two. Neither wins, and they keep retrying until the partition heals.
That is the reason a converged cluster does not split into two leaders: the denominator survives the partition, and two disjoint groups cannot both be a majority of the same five. Two leaders need diverged views, not a dropped connection - see What It Guarantees.
Healing. Connectivity returns. Each side rediscovers the other through the protocol: a vote request or heartbeat resolves the peers, the views grow back to five, and quorum returns to three. If the far side had elected a leader at a higher term, the term comparison settles it - A adopts the higher term and steps down, and one leader remains. If nobody outranked A, its heartbeats simply reach D and E again and they follow.
What a leader on the wrong side does. If A had ended up in the minority instead, its heartbeat tick would have found fewer reachable members than its quorum requires, and it would have stepped down on its own within one election timeout - without needing to hear from the majority side.
State, all safe to call from within your callbacks:
Membership:
Messaging:
Broadcast sends to every member of the current view, addressing each by PID where known and by name otherwise, and returns how many targets failed together with the first error. It does not stop at the first failure.
Exit handling:
Off by default, in which case any exit signal terminates the actor. Turn it on when the actor links to children whose failure should not remove this node from the cluster - an application group member is not restarted, so an untrapped exit takes the node out of the election for good.
Start on promotion, stop on demotion, and check on every tick rather than trusting a flag set long ago:
A request can arrive at any node. Handle it locally when you lead, forward when you do not, and reject when there is no leader - do not queue for one that may never appear:
HandleBecomeFollower runs on the actor's goroutine, so it cannot wait for in-flight work. Mark the actor as not leading, let the work notice on its next step, and keep the teardown itself immediate:
HandleInspect reports the full election state. Passing item names returns only those keys; pass help for the list, and an unknown item comes back as <unknown item> rather than silently missing.
The keys, grouped by what they answer:
Role and term - ergo:state (leader, candidate, follower, unclustered), ergo:leader (this node's own belief, as a boolean), ergo:leader_pid, ergo:leader_node, ergo:term, ergo:voted_for, ergo:term_changed_at
Membership - ergo:cluster (the cluster id this actor belongs to), ergo:view_size, ergo:quorum, ergo:min_cluster_size, ergo:peers, ergo:peers_list, ergo:declared, ergo:bootstrap, ergo:unreachable (each member whose connection dropped, with how long ago), ergo:ghost_ttl
The current election - ergo:votes_granted, ergo:votes_count
Liveness - ergo:election_timer_armed, ergo:heartbeat_timer_armed, ergo:heartbeat_in_last, ergo:heartbeat_out_last, ergo:election_timeout_min, ergo:election_timeout_max, ergo:heartbeat_interval
Problems - ergo:dropped_by_reason (per-reason counters for every message the actor discarded), ergo:send_failing_peers, ergo:confirm_denied, ergo:last_denied_at
Three of those are worth knowing about before you need them. ergo:state distinguishes a stuck candidate from a healthy follower, which a boolean cannot. ergo:election_timer_armed distinguishes a node waiting to campaign from one that has stopped. And ergo:dropped_by_reason is often the only trace a discarded message leaves anywhere.
The election state uses the reserved ergo: prefix, the same convention the core behaviors follow. When embedding leader.Actor and overriding HandleInspect(), your keys are merged on top of base inspection data, so your own fields sit beside the election state and one of its keys is replaced only if you name it with the prefix.
For guidance on designing your own inspection surface, see Inspecting Actor State.
Observer shows the election state of any leader actor in the process view, updating live, and the process kind reflects the role - leader, follower or candidate - so a stuck candidate is visible in a process list without opening it.
The MCP surface of Observer exposes the same inspection as resources and tools an AI agent asks for on demand, across every node in the cluster from the one node that serves it. For a leader actor that means the whole election can be read in one pass: which node each replica thinks is leading, at which term, with which view and quorum - which is how a divergence between replicas becomes obvious rather than inferred.
// Local - errors are immediate
err := process.Send(localPID, message)
if err != nil {
// ErrProcessUnknown or ErrProcessMailboxFull
// You know immediately something is wrong
}
// Remote - errors are hidden
err = process.Send(remotePID, message)
if err != nil {
// Only reports local problems (serialization, no connection)
// Cannot report remote problems (process missing, mailbox full)
}
// Message sent to network, no idea if it arrivederr := process.SendImportant(remotePID, message)
if err != nil {
// Immediate errors:
// - ErrProcessUnknown: process doesn't exist on remote node
// - ErrProcessMailboxFull: process exists but mailbox is full
// - ErrTimeout: remote node received message but no confirmation
// - ErrNoConnection: cannot reach remote node
}
// If no error, message is definitely in the recipient's mailboxfunc (a *Actor) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case CriticalUpdate:
// This message must be delivered or we need to know it failed
if err := a.SendImportant(targetPID, msg); err != nil {
a.Log().Error("failed to send critical update: %s", err)
return err
}
a.Log().Info("critical update confirmed delivered")
}
return nil
}func (a *Actor) Init(args ...any) error {
// Enable important delivery for all messages from this process
a.SetImportantDelivery(true)
return nil
}
func (a *Actor) HandleMessage(from gen.PID, message any) error {
// Send uses important delivery automatically
err := a.Send(targetPID, message)
if err != nil {
// Immediate confirmation or error
}
return nil
}// Caller
result, err := process.Call(target, request)
// Handler
func (h *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
result := h.process(request)
return result, nil // Framework sends with SendResponse
}// Caller
result, err := process.Call(target, request)
// Handler
func (h *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
h.SetImportantDelivery(true) // Or use SendResponseImportant explicitly
result := h.process(request)
return result, nil // Framework sends with SendResponseImportant
}// Caller
result, err := process.CallImportant(target, request)
// Handler
func (h *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
result := h.process(request)
return result, nil // Regular SendResponse
}// Caller
result, err := process.CallImportant(target, request)
// Handler
func (h *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
h.SetImportantDelivery(true)
result := h.process(request)
return result, nil // Framework sends with SendResponseImportant
}type Coordinator struct {
act.Actor
participants []gen.PID
}
func (c *Coordinator) Prepare() error {
c.SetImportantDelivery(true) // FR-2PC for all messages
// Phase 1: Prepare
for _, p := range c.participants {
result, err := c.CallImportant(p, PrepareRequest{})
if err != nil {
// Participant unreachable - abort
return c.abort()
}
if result != "yes" {
// Participant voted no - abort
return c.abort()
}
}
// Phase 2: Pre-commit (guaranteed delivery)
for _, p := range c.participants {
result, err := c.CallImportant(p, PreCommitRequest{})
if err != nil {
// This is a problem - participant didn't receive pre-commit
// But FR-2PC guarantees we know immediately
return c.handlePreCommitFailure(p, err)
}
}
// Phase 3: Commit (guaranteed delivery)
for _, p := range c.participants {
_, err := c.CallImportant(p, CommitRequest{})
if err != nil {
// Participant didn't receive commit
// Need recovery protocol
return c.handleCommitFailure(p, err)
}
}
return nil
}// Local send - immediate error, important flag ignored
err := process.SendImportant(localPID, message)
if err != nil {
// ErrProcessUnknown or ErrProcessMailboxFull
// No ACK needed, mailbox operation is synchronous
}type MetaBehavior interface {
Init(process MetaProcess) error
Start() error
HandleMessage(from PID, message any) error
HandleCall(from PID, ref Ref, request any) (any, error)
Terminate(reason error)
HandleInspect(from PID, item ...string) map[string]string
}type UDPServer struct {
gen.MetaProcess
socket net.PacketConn
target gen.PID
}
func (u *UDPServer) Init(process gen.MetaProcess) error {
u.MetaProcess = process
u.target = process.Parent()
return nil
}
func (u *UDPServer) Start() error {
// External Reader - continuous read loop
for {
buf := make([]byte, 65536)
n, addr, err := u.socket.ReadFrom(buf)
if err != nil {
return err
}
u.Send(u.target, Datagram{Data: buf[:n], From: addr})
}
}
func (u *UDPServer) HandleMessage(from gen.PID, message any) error {
// Actor Handler - write on demand
switch msg := message.(type) {
case SendDatagram:
u.socket.WriteTo(msg.Data, msg.To)
}
return nil
}
func (u *UDPServer) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
return nil, nil
}
func (u *UDPServer) Terminate(reason error) {
u.socket.Close()
}
func (u *UDPServer) HandleInspect(from gen.PID, item ...string) map[string]string {
return map[string]string{"local_addr": u.socket.LocalAddr().String()}
}type Server struct {
act.Actor
}
func (s *Server) Init(args ...any) error {
socket, err := net.ListenPacket("udp", ":8080")
if err != nil {
return err
}
udpServer := &UDPServer{socket: socket}
alias, err := s.SpawnMeta(udpServer, gen.MetaOptions{})
if err != nil {
socket.Close()
return err
}
s.Log().Info("UDP server listening on :8080 as %s", alias)
return nil
}func (h *Handler) Init(process gen.MetaProcess) error {
h.MetaProcess = process
cancel, err := h.SendEvery(h.Parent(), Heartbeat{ID: h.ID()}, 5*time.Second)
h.stopHeartbeat = cancel
return err
}type TCPConnection struct {
gen.MetaProcess
conn net.Conn
bytesIn uint64 // accessed by both goroutines
bytesOut uint64 // accessed by both goroutines
}
func (t *TCPConnection) Start() error {
// External Reader
buf := make([]byte, 4096)
for {
n, err := t.conn.Read(buf)
if err != nil {
return err
}
atomic.AddUint64(&t.bytesIn, uint64(n))
t.Send(t.Parent(), Data{Bytes: buf[:n]})
}
}
func (t *TCPConnection) HandleMessage(from gen.PID, message any) error {
// Actor Handler
if msg, ok := message.(Data); ok {
n, err := t.conn.Write(msg.Bytes)
atomic.AddUint64(&t.bytesOut, uint64(n))
return err
}
return nil
}
func (t *TCPConnection) HandleInspect(from gen.PID, item ...string) map[string]string {
// Actor Handler
in := atomic.LoadUint64(&t.bytesIn)
out := atomic.LoadUint64(&t.bytesOut)
return map[string]string{
"bytes_in": fmt.Sprintf("%d", in),
"bytes_out": fmt.Sprintf("%d", out),
}
}func (r *FileReader) Start() error {
file, _ := os.Open(r.filename)
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
r.Send(r.processor, Line{Text: scanner.Text()})
}
return scanner.Err()
}func (e *CommandExecutor) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case RunCommand:
output, err := exec.Command(msg.Cmd, msg.Args...).Output()
e.Send(from, CommandResult{Output: output, Error: err})
}
return nil
}func (t *TCPConnection) Start() error {
// Continuous reading
for {
n, err := t.conn.Read(buf)
if err != nil {
return err
}
t.Send(t.target, Received{Data: buf[:n]})
}
}
func (t *TCPConnection) HandleMessage(from gen.PID, message any) error {
// On-demand writing
if msg, ok := message.(Send); ok {
_, err := t.conn.Write(msg.Data)
return err
}
return nil
}func (t *TCPServer) Start() error {
for {
conn, err := t.listener.Accept()
if err != nil {
return err
}
handler := &TCPConnection{conn: conn}
if _, err := t.Spawn(handler, gen.MetaOptions{}); err != nil {
conn.Close()
t.Log().Error("failed to spawn connection handler: %s", err)
}
}
}gen.ApplicationSpec{
Name: "scheduler",
Network: gen.ApplicationNetwork{
RegisterTypes: leader.NetworkTypes(),
RegisterErrors: leader.ErrorTypes(),
},
Group: []gen.ApplicationMemberSpec{
{Name: "coordinator", Factory: factoryCoordinator},
},
}node.Network().RegisterTypes(leader.NetworkTypes())
node.Network().RegisterErrors(leader.ErrorTypes())package main
import (
"time"
"ergo.services/actor/leader"
"ergo.services/ergo"
"ergo.services/ergo/gen"
)
type coordinator struct {
leader.Actor
running bool
}
type messageDiscoverPeers struct{}
func factoryCoordinator() gen.ProcessBehavior {
return &coordinator{}
}
func (c *coordinator) Init(args ...any) (leader.Options, error) {
// Join from a handler frame, not from here: the options below have not been
// applied yet, so a vote sent from Init would carry an empty ClusterID and be
// dropped by the receiver's own guard.
if err := c.Send(c.PID(), messageDiscoverPeers{}); err != nil {
return leader.Options{}, err
}
return leader.Options{
ClusterID: "scheduler",
MinClusterSize: 3,
}, nil
}
func (c *coordinator) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageDiscoverPeers:
for _, node := range discoverNodes() { // your discovery, whatever it is
c.Join(gen.ProcessID{Name: "coordinator", Node: node})
}
// Re-run it: peers appear and disappear, and a node that starts before its
// peers must keep looking.
if _, err := c.SendAfter(c.PID(), messageDiscoverPeers{}, 5*time.Second); err != nil {
return err
}
}
return nil
}
func (c *coordinator) HandleBecomeLeader() error {
c.Log().Info("became leader at term %d", c.Term())
c.running = true
return c.Send(c.PID(), messageTick{})
}
func (c *coordinator) HandleBecomeFollower(leader gen.PID) error {
c.Log().Info("no longer leader, following %s", leader)
c.running = false
return nil
}
func main() {
node, err := ergo.StartNode("n1@localhost", gen.NodeOptions{})
if err != nil {
panic(err)
}
defer node.Stop()
node.Network().RegisterTypes(leader.NetworkTypes())
node.SpawnRegister("coordinator", factoryCoordinator, gen.ProcessOptions{})
node.Wait()
}leader.Options{
ClusterID: "scheduler", // required
MinClusterSize: 3, // default 3
Bootstrap: peers, // optional, []gen.ProcessID
ElectionTimeoutMin: 150, // ms, default 150
ElectionTimeoutMax: 300, // ms, default 300
HeartbeatInterval: 50, // ms, default 50
GhostTTL: 5000, // ms, default 5000
}type ActorBehavior interface {
gen.ProcessBehavior
Init(args ...any) (Options, error)
HandleMessage(from gen.PID, message any) error
HandleCall(from gen.PID, ref gen.Ref, request any) (any, error)
Terminate(reason error)
HandleInspect(from gen.PID, item ...string) map[string]string
// Leadership
HandleConfirmLeader() (bool, error)
HandleBecomeLeader() error
HandleBecomeFollower(leader gen.PID) error
// Membership
HandlePeerJoined(peer gen.PID) error
HandlePeerLeft(peer gen.PID) error
// Framework message classes
HandleEvent(event gen.MessageEvent) error
HandleSpan(span gen.TracingSpan) error
HandleLog(message gen.MessageLog) error
}func (c *coordinator) HandleConfirmLeader() (bool, error) {
return c.lease.Held(), nil // cheap read of locally cached state
}UPDATE resources SET owner_token = :token
WHERE id = :id AND (owner_token IS NULL OR owner_token < :token)c.IsLeader() // bool
c.Leader() // gen.PID of the leader this node recognises, zero if none
c.Term() // uint64
c.ClusterID() // stringc.Peers() // []gen.PID of the peers whose PID is known
c.PeerCount() // int
c.HasPeer(pid) // bool
c.Bootstrap() // []gen.ProcessID as configured
c.Join(procID) // declare a peer
c.Leave(node) // withdraw onefailed, err := c.Broadcast(message)c.SetTrapExit(true) // an exit from anyone but the parent arrives as a message
c.TrapExit() // boolfunc (c *coordinator) HandleBecomeLeader() error {
return c.Send(c.PID(), messageTick{})
}
func (c *coordinator) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageTick:
if c.IsLeader() == false {
return nil // demoted between ticks
}
c.doWork()
_, err := c.SendAfter(c.PID(), messageTick{}, time.Second)
return err
}
return nil
}func (c *coordinator) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
if c.IsLeader() {
return c.handle(request), nil
}
leader := c.Leader()
if leader == (gen.PID{}) {
return nil, gen.ErrNotAllowed // no leader right now
}
return c.Call(leader, request)
}func (c *coordinator) HandleBecomeFollower(leader gen.PID) error {
c.running = false // the tick handler checks this and stops rescheduling
return nil
}The health actor provides Kubernetes-compatible health probe endpoints for Ergo applications. Instead of each application building its own HTTP health check logic, the health actor centralizes probe management into a single process that serves /health/live, /health/ready, and /health/startup endpoints.
Actors register named signals with the health actor, optionally sending periodic heartbeats. The health actor aggregates signal states and serves HTTP responses that Kubernetes (or any other orchestrator) can use to determine whether to restart a pod, route traffic to it, or wait for it to finish starting.
Kubernetes uses three types of probes to manage pod lifecycle:
Liveness: Is the application alive? A failing liveness probe causes Kubernetes to restart the pod. Use this for detecting deadlocks, infinite loops, or corrupted state that prevents the application from functioning.
Readiness: Can the application serve traffic? A failing readiness probe removes the pod from service endpoints. Use this for temporary conditions like database connection loss, cache warming, or downstream dependency outages where restarting would not help.
Startup: Has the application finished initializing? A failing startup probe prevents liveness and readiness checks from running. Use this for slow-starting applications that need time to load data, run migrations, or establish connections before health checks begin.
In traditional applications, you implement these probes as HTTP handlers that check internal state. In actor systems, the "state" is distributed across many processes. A database connection actor, a cache warmer, and a message queue consumer each know their own status, but no single actor knows the overall health.
The health actor solves this by accepting signal registrations from any actor in the system. Each actor reports its own status, and the health actor aggregates these signals into per-probe HTTP responses.
The health actor follows a registration and heartbeat pattern:
Actors register signals: Each actor that contributes to health sends a RegisterRequest to the health actor (synchronous Call), specifying a signal name, which probes it affects, and an optional heartbeat timeout. The Call returns after the signal is registered, preventing race conditions with subsequent heartbeats.
The health actor monitors registrants: When a signal is registered, the health actor monitors the registering process. If that process terminates, all its signals are automatically marked as down.
Actors send heartbeats: For signals with a timeout, the registering actor periodically sends MessageHeartbeat
The health actor extends gen.ProcessBehavior with a specialized interface:
All callbacks have default (no-op) implementations. You only override what you need.
HandleSignalDown is called when a signal transitions from up to down, due to heartbeat timeout, process termination, or explicit MessageSignalDown. Use this for alerting, logging, or triggering recovery actions.
HandleSignalUp is called when a signal transitions from down to up, via heartbeat recovery or explicit MessageSignalUp. Use this to log recovery events or update external systems.
Spawn the health actor and register it with a name so other actors can find it:
Default configuration:
Host: localhost
Port: 3000
Path: /health
With no signals registered, all three endpoints return 200 with {"status":"healthy"}. This means a freshly started health actor does not block deployment. Signals opt in to health checking; only registered signals can cause a probe to fail.
Host determines which network interface the HTTP server binds to. Use "0.0.0.0" for production/containerized environments.
Port should not conflict with other services on the same pod.
Path sets the prefix for health endpoints. Endpoints are registered as Path+"/live", Path+"/ready", Path+"/startup". Change this when the default conflicts with your routing or when deploying behind a reverse proxy. For example, with Path: "/k8s" the endpoints become /k8s/live, /k8s/ready, /k8s/startup.
CheckInterval controls how frequently the actor checks for expired heartbeats. The actor sends itself a timer message at this interval and iterates over all signals with a non-zero timeout, marking expired ones as down. Shorter intervals detect failures faster but increase message processing overhead. For most applications, 1-2 seconds provides a good balance.
Mux accepts an external *http.ServeMux. When provided, the health actor registers its handlers on this mux and skips starting its own HTTP server. This is useful when you want to serve health endpoints alongside other HTTP handlers on a single port, for example, combining with the actor.
When Mux is set, Host and Port are ignored.
Each signal specifies which probes it affects using a bitmask:
Combine probes with bitwise OR. A database connection that affects both liveness and readiness:
A migration signal that only affects startup:
When Probe is 0, it defaults to ProbeLiveness.
The package provides convenience functions:
Register and Unregister use synchronous Call to confirm the operation completed. This prevents race conditions where a heartbeat or status update arrives before the signal is registered. All other helpers use async Send.
The to parameter accepts anything that identifies a process: a gen.Atom name, gen.PID, gen.ProcessID, or gen.Alias.
If you prefer sending messages directly instead of using helpers:
Registering these types with EDF is the caller's job, not the library's, and it has to happen before the node carries any traffic. Registration is node-scoped, so it cannot be done from a package init(), and doing it in the actor's own Init would already be too late on a node whose health process starts after a connection is up.
Two places work. Declare them on the application that hosts the actor, which is processed during application load before any of its processes spawn:
Or register them on the node directly, as the example above does. Init refuses to start otherwise. Once they are registered, actors on remote nodes can register signals with a health actor on any node in the cluster.
If you run health through rather than spawning it yourself, this is already done: radar declares both sets in its own ApplicationSpec.Network, and application load runs before any of its processes spawn.
The heartbeat pattern is the primary mechanism for detecting failures in long-running dependencies. The actor that owns a resource (database, external API, message queue) knows best whether the resource is healthy. It registers a signal with a timeout and sends periodic heartbeats as long as the resource is available.
Choose the heartbeat interval to be at least 2x shorter than the timeout. This provides one missed heartbeat as a safety margin before the signal is marked as down.
When the actor crashes, the health actor receives a gen.MessageDownPID (because it monitors the registrant) and marks all signals from that process as down. Heartbeat timeout is a secondary detection mechanism for situations where the process is alive but the resource it manages is not, for example, a database connection pool actor that is running but has lost all connections.
Each endpoint evaluates only signals registered for that specific probe. A signal registered for ProbeLiveness only does not affect /health/ready or /health/startup.
200 OK: all signals for this probe are up, or no signals are registered.
503 Service Unavailable: at least one signal for this probe is down.
Healthy response with signals:
Unhealthy response:
Healthy response with no signals (probe has no registered signals):
The timeout field appears only for signals that have a heartbeat timeout configured. Signals without timeout omit this field.
The health actor detects failures through three mechanisms:
When a process that registered signals terminates (normally or abnormally), the health actor receives gen.MessageDownPID through its monitor. All signals from that process are immediately marked as down. This is the fastest and most reliable detection mechanism.
For signals with a non-zero timeout, the health actor periodically checks whether the last heartbeat was received within the timeout window. If a heartbeat is overdue, the signal is marked as down and HandleSignalDown is called.
Heartbeat timeout catches situations where the process is alive but the resource it monitors is unavailable. The process continues to run (so no MessageDownPID arrives) but stops sending heartbeats because the resource check fails.
Actors can explicitly report status changes using MessageSignalUp and MessageSignalDown. Use this when you can detect failures immediately without waiting for a timeout, for example, catching a database connection error in a callback and immediately marking the signal as down, then marking it up again when the connection is re-established.
Embed health.Actor in your own struct to add custom behavior:
Override HandleMessage to handle application-specific messages alongside health management. The health actor dispatches its own types internally (RegisterRequest/UnregisterRequest via HandleCall, MessageHeartbeat/MessageSignalUp/MessageSignalDown via HandleMessage); only unrecognized messages are forwarded to your callbacks.
Configure Kubernetes probes to point to the health actor's endpoints:
Adjust initialDelaySeconds based on how long your application takes to start and register signals. The startup probe with failureThreshold: 30 and periodSeconds: 2 gives the application 60 seconds to complete initialization before Kubernetes considers it failed.
Register liveness and readiness signals with heartbeat:
If db.Ping() fails, no heartbeat is sent, and the signal times out. The health actor marks it as down, causing Kubernetes to remove the pod from service endpoints (readiness) and eventually restart it (liveness).
Use the startup probe for slow initialization:
While migrations run, the startup probe returns 503, preventing Kubernetes from running liveness and readiness checks. Once migrations complete, the signal is unregistered and the startup probe returns 200.
Use readiness-only signals for recoverable issues:
Register the signal for ProbeReadiness only. The pod stops receiving traffic during the outage but is not restarted, since the cache connection will likely recover on its own.
The health actor integrates with Observer via HandleInspect(). Inspecting the health actor shows the endpoint URL, signal count, check interval, and current status of each registered signal.
If your node needs both health probes and Prometheus metrics, consider the application. It runs the health actor and metrics actor together on a single HTTP port and provides helper functions so your actors don't need to import either package directly.
Grouping and Managing Actors as a Unit
An application groups related actors and manages them as a unit. Instead of starting individual processes and tracking their lifecycles manually, you define an application that specifies which actors to start, in what order, and how the group should behave if individual actors fail.
Think of an application as a recipe. It lists the components (actors and supervisors), describes their startup order, and specifies the rules for what happens when things go wrong. The node follows this recipe when starting the application and monitors the running components according to the specified mode.
Starting processes one at a time works for simple systems. But as complexity grows, you face coordination problems. Which processes should start first? What if one fails to start - do you continue or abort? If a critical component terminates, should the service keep running in a degraded state or shut down cleanly?
These aren't implementation details - they're architectural decisions about your service's structure and fault tolerance policy. Applications let you declare these decisions explicitly rather than scattering the logic throughout your code. The specification documents what your service consists of. The mode declares your termination policy. The framework enforces both.
Applications embed app.Application and implement Load:
Load returns the application specification: what this application consists of and how it should behave. The embedded app.Application provides helper methods (Node, Log, Name, AddTag, SetWeight, and so on) bound to the running application. They are available from inside Load and all other callbacks.
The full lifecycle interface is:
app.Application implements PreLoad and provides default no-op Init, Start, Stop, Terminate. Override only the callbacks you need.
Do not override
PreLoad. It is the framework entry point that binds the runtime application before dispatchingLoad. Overriding breaks the binding and triggers a panic on the next default callback.
The Group lists processes to start. Processes start in the order listed. If a process has a Name, it's registered with that name, making it discoverable. Processes without names are anonymous.
Application names and process names exist in separate namespaces. An application named "api" and a process named "api" do not conflict - you can have both registered simultaneously. However, using the same name for both creates confusion when reading code or debugging. Avoid identical names even though the framework allows it.
The mode determines what happens when a member of the Group terminates. It watches the members you listed, not every process of the application: a process spawned deeper in the tree is the concern of the supervisor above it, and reaches the application's attention only if its death takes a member down.
Leaving Mode unset means Temporary. The zero value is not one of the three modes - ApplicationModeTemporary is 1 - so load substitutes it, which is worth knowing because Temporary is the most permissive of the three. Two further checks happen at load and have no defaults: a spec with an empty Group is refused with gen.ErrApplicationEmpty, and one with an empty Name with gen.ErrApplicationName.
Temporary Mode - A member terminating never stops the application by itself. The application stops when the last member is gone, with reason gen.TerminateReasonNormal. This mode is for applications where components can fail and restart independently (typically via supervisors) without stopping the whole application.
Transient Mode - The application stops if a member terminates abnormally (crashes, panics, errors), and the member's reason becomes the application's. Normal termination doesn't trigger shutdown; as in temporary mode, the application stops once the last member is gone. Use this mode when abnormal failures indicate a systemic problem that requires stopping the entire service.
Permanent Mode - The application stops if any member terminates, regardless of reason, again with that member's reason. Even normal termination of one member triggers shutdown of all the others and the application itself. This mode is for applications where all components must run together - if one stops, the whole application is incomplete.
The rules apply while the application is still starting. A permanent application whose member dies before the rest of the group has spawned never reaches Running: the start unwinds and returns an error.
Each callback has a clear responsibility in the application lifecycle:
Load: declarative. Validate configuration, return the spec. Avoid side effects.
Init: pre-start. Open external resources the Group processes will need: database connection pools, caches, message queues. Returning an error aborts the start; Terminate is not called.
Each of Init, Start, Stop receives a gen.Ref carrying a deadline. Check ref.IsAlive() to detect when your callback has exceeded its timeout budget. If it has, the framework has already moved on; unwind gracefully and return.
Timeouts are configured in ApplicationSpec or overridden per-start via ApplicationOptions:
Default is 15 seconds for each.
A connection pool shared by several actors outlives any one of them. An actor restarts under its supervisor, and reopening the pool on every restart would be both slow and wrong. The owner is therefore the application, not the actor: Init opens the resource, Terminate closes it, and a field on the application struct holds it in between.
A process of the application reaches the owner through its runtime application:
Init finishes before the first member spawns, so every process finds the resource ready. Terminate runs only after the last of them is gone, so a worker may use the pool right to the end of its own Terminate callback: flush a batch, release a lock, write a closing row.
Keep live handles out of the environment. Env values are copied into every process at spawn, and with Security.ExposeEnvInfo enabled they are also serialized into ApplicationInfo for other nodes to read, which a database handle cannot survive. The DSN, the pool size and the timeouts belong there; the object itself belongs on the application.
An application is not an actor. It has no mailbox and no supervisor, so it cannot restart a connection that dropped. Clients that carry their own pool and reconnect logic (database/sql, go-redis) fit this ownership naturally. A resource without that, a raw socket or a session that has to be re-established by hand, belongs to an actor under a supervisor instead, with the handle never leaving it: callers send it messages, and losing the connection becomes an ordinary supervision event.
Applications go through two phases: loading and starting.
Loading calls your Load callback, validates the specification, and registers the application with the node. The application is in Loaded state but not running. This separation allows you to load multiple applications and resolve dependencies before starting any of them.
Starting follows this sequence:
State transitions from Loaded to Initializing.
Init callback runs (within InitTimeout). On error or timeout the state reverts to Loaded and Terminate is not called.
A stop requested while the application is starting, either through ApplicationStop or by the mode reacting to a member that died, interrupts the sequence: no further members are spawned, the ones already running are stopped, and ApplicationStart returns gen.ErrApplicationStopping.
Per-process gen.ProcessOptions.InitTimeout has a hard cap of 15 seconds inside an application context. Setting a higher value returns gen.ErrNotAllowed and prevents the application from starting.
Applications can depend on other applications or network services. If application B depends on application A, the node ensures A is running before starting B. Dependencies are declared in ApplicationSpec.Depends.
This allows you to structure complex systems with clear startup ordering. A database connection pool application starts before the API server application. The API server starts before the web frontend application. The framework handles the ordering automatically.
ApplicationSpec.Network is the declarative form for everything the application contributes to the node's network. Currently it covers wire-format registration:
Entries are processed during ApplicationLoad, before any process in the application is spawned. If the node's network mode is NetworkModeDisabled, the entries are silently ignored and the application loads as usual. For details on what to register and why, see .
Applications stop in three ways: ApplicationStop, ApplicationStopForce, or the mode reacting to a member that terminated. All three run the same teardown, in this order:
The state becomes Stopping. From here the application takes no new processes: a spawn into it fails with gen.ErrApplicationStopping.
The Stop callback runs, within StopTimeout, while everything is still up.
Every group member receives an exit signal. A member that is a supervisor takes its subtree down with it.
ApplicationStop returns when all of that is done, so by the time the call comes back the resources are closed and the application is already Loaded. It waits up to five seconds; use ApplicationStopWithTimeout when a teardown legitimately takes longer. Running out of that wait returns gen.ErrApplicationStopping and does not cancel anything: the teardown continues.
ApplicationStopForce skips the Stop callback, kills the processes instead of asking them to stop, and returns without waiting. Terminate still runs, once the last process is gone. Less graceful, but it does not depend on processes cooperating.
Step 4 covers the whole application, not just the members. A process that a member spawned without a supervisor above it has nothing left to stop it once its parent is gone, so the application sends it an exit signal itself and logs which processes those were. Anything that still does not stop is killed after StopTimeout, again named in the log. Both lines are worth reading: they name the processes that escaped supervision.
Stopping by mode goes through the same steps, so the Stop callback runs there too. In temporary mode it happens when the last member is gone, in transient and permanent mode when a member terminates in a way the mode does not tolerate.
A stop is not a restart. The node does not bring a stopped application back; recovery is left to you. The application could announce its own death from Terminate by sending a message somewhere, but it is better left to the bus: hand-wiring notifications couples the application to whoever cares and reinvents what events already do. The node publishes the stop for you as a gen.MessageCoreApplicationStopped carrying the application name and the reason it stopped; interested processes subscribe and the application never tracks who is watching. Subscribe to gen.CoreEvent to act on it, locally or, since events cross nodes, from one observer watching every node in the cluster. See .
Applications have environment variables that all their processes inherit. These override node-level variables but are overridden by process-specific variables. This creates a natural layering: node provides defaults, application provides service-specific values, processes can override for their specific needs.
The runtime application is available from two places.
Inside the application's own callbacks (Load, Init, Start, Stop, Terminate), the embedded app.Application is bound to the running application before Load runs. Methods promoted through the embed (a.Node(), a.Log(), a.Name(), a.Tags(), a.AddTag()
Processes that belong to the application access it through Process.Application(). This returns a gen.Application interface bound to the same runtime, so a worker can introspect or mutate its parent application:
Process.Application() returns nil for processes spawned outside any application (directly via node.Spawn).
Both views of the membership are available from node.ApplicationInfo(): Group lists the PIDs of the members you declared, and ProcessesTotal counts everything the application owns, those members and every process they spawned. The two differ by exactly the depth of the tree below the group, and ProcessesTotal is what the teardown waits for: it reaches zero the moment before Terminate runs. node.ApplicationProcessList() enumerates that same full set.
Applications have their own log source distinct from the node. Log messages emitted via a.Log() from inside any callback are tagged with the application's identity (node hash and application name), making them filterable across cluster-wide log aggregation.
In plain-text output the source appears as App#<NodeHash.'name'>, mirroring the format of gen.PID and gen.ProcessID. In structured JSON output, the source carries type: application, the node hash, the application name, and the current mode. Custom loggers can dispatch on gen.MessageLogApplication to format application logs differently from node, process, or network logs.
Running multiple instances of the same application across a cluster creates a selection problem. Which instance should handle the request? In blue/green deployments, you run two versions and route traffic based on readiness. Canary deployments send a percentage to the new version. Some instances enter maintenance mode while others serve production traffic.
Tags provide metadata for making these decisions. Label each application instance with tags describing its deployment state, version, or role:
Tags are always available through node.ApplicationInfo() or remoteNode.ApplicationInfo(). For clusters using centralized registrars (etcd, Saturn), tags are also published during application route registration. This enables cluster-wide discovery: query the registrar and receive all application instances with their tags.
Tags and weight can also be mutated at runtime through the gen.Application interface. From inside any application callback, the embedded app.Application provides AddTag, RemoveTag, SetTags, SetWeight. Processes within the application can access the same methods through Process.Application():
Mutations push an updated route to the registrar so other nodes see the change on next route refresh. This lets you flip an instance into maintenance mode, mark it ready after warmup, or adjust its weight based on load, all from inside the application.
The embedded in-memory registrar does not support application route registration, so tags in single-node or statically-routed deployments are only accessible via direct ApplicationInfo() calls, not through resolver queries.
In clusters with centralized registrars, resolve the application and chain filter methods on the result:
WithTags(tags...) keeps only routes that have all the given tags. WithoutTags(tags...) drops routes that contain any of the given tags. WithState(states...) keeps routes in the given states. Each method returns a fresh ApplicationRoutes so chaining is non-destructive; the original slice is unchanged.
Common tag patterns:
Blue/green deployment: "blue", "green"
Canary rollout: "canary", "stable"
Maintenance state: "maintenance", "active", "draining"
The release itself needs no tag: every route carries Version from the application's spec, so route.Version already tells one rollout from another. Tags express the role an instance plays in a deployment, which is a different question from which build it runs.
Geographic region: "us-east", "eu-west"
Tags separate deployment strategy from application code. Your application doesn't know it's the "blue" deployment - that's configuration. The routing logic queries tags and makes decisions based on current cluster state.
Applications contain multiple processes with specific responsibilities. An API server handles requests. A connection pool manages database connections. A cache manager stores frequently accessed data. These are logical roles, but the actual process names might be versioned, generated, or environment-specific.
The Map field bridges this gap. Define a mapping from logical role (string) to actual process name (Atom):
To communicate with a process by role, get the application info, look up the role in the map, then use the returned name:
This works for both local and remote applications. When querying a remote application, RemoteNode.ApplicationInfo() retrieves the map from the remote node, letting you discover process names without prior knowledge of the remote application's internal structure.
Why use mapping:
Version changes: Update "api_server_v2" to "api_server_v3" without changing client code
Implementation swaps: Map "db" to different pool implementations based on deployment
Remote discovery: Remote nodes query the map to find process names in foreign applications
Stable interface: Clients depend on roles ("api", "db"), not implementation details
The map provides a service contract. External code knows the application has an "api" role and a "db" role. The actual implementations can change as long as the roles remain consistent.
An application is invoked by code that lives outside it. If that code sends the application's processes raw messages, it has to know their registered names and construct the right message types by hand. That couples every caller to your internals: rename a process or change a message and every call site breaks.
The idiomatic alternative is to give the application package a set of exported helper functions that hide those details. A helper takes the caller's process handle and sends or calls the application's local instance by its registered name (a gen.Atom), which stays private to the package:
A caller writes orders.Place(process, "sku-1", 3) from inside its own callback. It never constructs a message and never learns the process name.
The messaging stays private. Because callers go through the functions, message types like messagePlace and statusRequest never appear in any other package and can be unexported. A caller depends only on the helper signatures and ordinary Go types (item string, qty int), never on your message layout, so you can add a field, split a message, or rename one without changing a single call site. Exposing the message types instead would force them to be exported, since callers construct them directly, and freeze their shape into your public API. Only messages that actually cross nodes need exported fields and EDF registration; a helper talking to the local instance keeps them fully private.
The helper receives the caller's handle as an argument rather than reading a package global, which would break addressing in a multi-node cluster and make the package hard to test. That handle is normally a gen.Process. When there is no actor context, for example a web server translating an HTTP request into a call on another node, the helper takes a gen.Node and addresses the target explicitly with node.Call(gen.ProcessID{Name: name, Node: peer}, ...); that fuller form is the one case where the node is named.
An application composed of several sub-components re-exports their helpers under one namespace, so callers depend on the application and never import or name the parts. application/radar is the model: radar.RegisterService delegates to the health actor and radar.CounterAdd to the metrics actor, and a caller never learns radar is assembled from separate health and metrics actors.
Applications provide structure to your actor system. Instead of scattered process creation throughout your code, applications centralize the "what runs in this service" question. The specification documents your system's structure. The mode declares your fault tolerance policy. The dependency mechanism ensures correct startup ordering.
This organization becomes especially valuable in distributed systems where services start on different nodes. An application can be started remotely on another node, bringing all its components with the correct configuration and dependencies.
For more details on application lifecycle and options, refer to the gen.ApplicationBehavior and gen.ApplicationSpec documentation in the code.
Web UI and MCP surface for monitoring, inspecting and managing Ergo nodes
Observer is an application that embeds into your node and opens it up for inspection. One HTTP listener serves three things: the web UI built into the binary, the API that UI runs on, and an MCP surface for an AI agent. All three read the same node through the built-in system application, and all three are bounded by the same authorization, apart from two deliberately open paths noted below.
For the tour of the UI, see . For working through an agent, see . This page is how you add it and what you can configure.
Open http://localhost:9911 in your browser, and point an MCP client at http://localhost:9911/mcp.
Nothing has to be deployed on the nodes you inspect. Observer talks to the system application that every Ergo node runs, so one node with Observer reaches the whole cluster.
Real-time inspection and management of Ergo nodes
A running Ergo node is not one program you can step through with a debugger. It is thousands of processes, each on its own goroutine, each handling its own messages, being restarted by supervisors as failures happen. You cannot pause one and read its variables the way you would a function, and adding print statements changes the very timing you are trying to understand.
Observer takes a different approach. It watches the live node the way the system itself does: what each process is doing right now, how messages travel between them, and where CPU and memory go. It embeds into the node, streams updates to your browser as they happen, and needs no changes to the processes it observes. And it is not read-only: from the same screen you can change a setting, send a message, or stop a process on the running system.
You deploy Observer on a single node and inspect the whole cluster from it. Every Ergo node runs the built-in system application, which is what Observer talks to, so switching to another node needs nothing extra deployed there. For installation and options, see .
To try it against a live cluster:
This starts a multi-node cluster with Observer, tracing, health probes, Prometheus metrics, and Grafana dashboards. Open http://localhost:9911
HTTP handlers read atomic state: The HTTP handlers read pre-built JSON responses from atomic values. The actor goroutine rebuilds these atomic values after every state change. No mutexes or channels are involved in serving HTTP requests.
CheckInterval: 1 second
MessageHeartbeat
async (Send)
Update heartbeat timestamp. Fields: Signal gen.Atom
MessageSignalUp
async (Send)
Mark a signal as up. Fields: Signal gen.Atom
MessageSignalDown
async (Send)
Mark a signal as down. Fields: Signal gen.Atom
{Path}/startup
ProbeStartup
200 healthy
RegisterRequest / RegisterResponse
sync (Call)
Register a signal. Fields: Signal gen.Atom, Probe Probe, Timeout time.Duration
UnregisterRequest / UnregisterResponse
sync (Call)
{Path}/live
ProbeLiveness
200 healthy
{Path}/ready
ProbeReadiness
Remove a signal. Fields: Signal gen.Atom
200 healthy
Start: post-start. The Group is running. Register health checks, export metrics, notify the load balancer that the instance is ready.Stop: pre-stop. Everything is still running and every resource is still open. Drain in-flight work, deregister health checks, mark unhealthy in the load balancer so traffic stops being routed here.
Terminate: post-stop. Every process of the application has terminated, its own Terminate callback included. Close resources opened in Init.
The framework spawns each process in Group in order. If a spawn fails after Init succeeded, the application unwinds: the members already spawned are stopped, and once they are gone Terminate runs to release what Init opened. ApplicationStart returns the spawn error.
State transitions from Initializing to Running.
Start callback runs (within StartTimeout). A timeout here is non-fatal; the application stays in Running state.
The application waits for every process it owns to terminate, the Terminate callback of each of them included.
The Terminate callback of the application runs and releases what Init opened.
The state becomes Loaded. The application can be started again, or unloaded.
a.Weight()a.SetWeight()node gen.NodeLoadLoada.Node()Start now takes an extra ref gen.Ref parameter: Start(ref gen.Ref, mode gen.ApplicationMode). If your old Start was an empty stub, delete it; the embed default handles it. If it had real logic, rewrite with the new signature.
Terminate(reason error) is unchanged. If empty, delete it; otherwise keep as is.
New optional callbacks Init and Stop exist on the interface but are no-op by default through the embed. Override them only if you need pre-start resource initialization or pre-stop drain logic. See Lifecycle Callbacks.
type ActorBehavior interface {
gen.ProcessBehavior
Init(args ...any) (Options, error)
HandleMessage(from gen.PID, message any) error
HandleCall(from gen.PID, ref gen.Ref, message any) (any, error)
HandleInspect(from gen.PID, item ...string) map[string]string
HandleSignalDown(signal gen.Atom) error
HandleSignalUp(signal gen.Atom) error
Terminate(reason error)
}package main
import (
"ergo.services/actor/health"
"ergo.services/ergo"
"ergo.services/ergo/gen"
)
func main() {
node, _ := ergo.StartNode("mynode@localhost", gen.NodeOptions{})
defer node.Stop()
// Required, and before the node carries any traffic. The actor refuses to
// start on a node that cannot decode its messages: a Register or Heartbeat
// lost in decoding is a signal silently missing from the probe answer.
node.Network().RegisterTypes(health.NetworkTypes())
node.Network().RegisterErrors(health.ErrorTypes())
node.SpawnRegister(gen.Atom("health"), health.Factory, gen.ProcessOptions{},
health.Options{Port: 8080})
// Endpoints:
// http://localhost:8080/health/live
// http://localhost:8080/health/ready
// http://localhost:8080/health/startup
node.Wait()
}options := health.Options{
Host: "0.0.0.0", // Listen on all interfaces
Port: 8080, // HTTP port
Path: "/health", // Path prefix (default: "/health")
CheckInterval: 2 * time.Second, // Heartbeat check interval
}mux := http.NewServeMux()
healthOpts := health.Options{Mux: mux}
node.SpawnRegister("health", health.Factory, gen.ProcessOptions{}, healthOpts)
metricsOpts := metrics.Options{Mux: mux}
node.Spawn(metrics.Factory, gen.ProcessOptions{}, metricsOpts)
// Serve the shared mux yourselfconst (
ProbeLiveness Probe = 1 << iota // 1: /health/live
ProbeReadiness // 2: /health/ready
ProbeStartup // 4: /health/startup
)health.Register(w, gen.Atom("health"), "db",
health.ProbeLiveness|health.ProbeReadiness, 5*time.Second)health.Register(w, gen.Atom("health"), "migrations",
health.ProbeStartup, 0)// Register a signal (sync Call -- blocks until registered)
health.Register(process, to, signal, probe, timeout)
// Remove a signal (sync Call -- blocks until removed)
health.Unregister(process, to, signal)
// Send heartbeat (async Send)
health.Heartbeat(process, to, signal)
// Manual control (async Send)
health.SignalUp(process, to, signal)
health.SignalDown(process, to, signal)gen.ApplicationSpec{
Network: gen.ApplicationNetwork{
RegisterTypes: health.NetworkTypes(),
RegisterErrors: health.ErrorTypes(),
},
}type DBWorker struct {
act.Actor
heartbeatTimer gen.CancelFunc
}
type messageHeartbeatTick struct{}
func (w *DBWorker) Init(args ...any) error {
// Register with 5-second heartbeat timeout
health.Register(w, gen.Atom("health"), "db",
health.ProbeLiveness|health.ProbeReadiness, 5*time.Second)
// Send heartbeat every 2 seconds (well within the 5s timeout)
w.heartbeatTimer, _ = w.SendAfter(w.PID(), messageHeartbeatTick{}, 2*time.Second)
return nil
}
func (w *DBWorker) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageHeartbeatTick:
health.Heartbeat(w, gen.Atom("health"), "db")
w.heartbeatTimer, _ = w.SendAfter(w.PID(), messageHeartbeatTick{}, 2*time.Second)
}
return nil
}
func (w *DBWorker) Terminate(reason error) {
if w.heartbeatTimer != nil {
w.heartbeatTimer()
}
}{"status":"healthy","signals":[{"signal":"db","status":"up","timeout":"5s"}]}{"status":"unhealthy","signals":[{"signal":"db","status":"down","timeout":"5s"},{"signal":"cache","status":"up"}]}{"status":"healthy"}type MyHealth struct {
health.Actor
}
func MyHealthFactory() gen.ProcessBehavior {
return &MyHealth{}
}
func (h *MyHealth) Init(args ...any) (health.Options, error) {
return health.Options{Port: 8080}, nil
}
func (h *MyHealth) HandleSignalDown(signal gen.Atom) error {
h.Log().Error("signal went down: %s", signal)
// Alert external monitoring, update metrics, trigger recovery
return nil
}
func (h *MyHealth) HandleSignalUp(signal gen.Atom) error {
h.Log().Info("signal recovered: %s", signal)
return nil
}apiVersion: v1
kind: Pod
spec:
containers:
- name: myapp
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
startupProbe:
httpGet:
path: /health/startup
port: 3000
failureThreshold: 30
periodSeconds: 2func (w *DBWorker) Init(args ...any) error {
health.Register(w, gen.Atom("health"), "postgres",
health.ProbeLiveness|health.ProbeReadiness, 10*time.Second)
w.scheduleHeartbeat()
return nil
}
func (w *DBWorker) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageHeartbeat:
if w.db.Ping() == nil {
health.Heartbeat(w, gen.Atom("health"), "postgres")
}
w.scheduleHeartbeat()
}
return nil
}func (w *Migrator) Init(args ...any) error {
health.Register(w, gen.Atom("health"), "migrations",
health.ProbeStartup, 0) // No timeout -- manual control
w.Send(w.PID(), messageRunMigrations{})
return nil
}
func (w *Migrator) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageRunMigrations:
if err := w.runMigrations(); err != nil {
health.SignalDown(w, gen.Atom("health"), "migrations")
return err
}
health.SignalUp(w, gen.Atom("health"), "migrations")
// Unregister since startup is complete
health.Unregister(w, gen.Atom("health"), "migrations")
}
return nil
}func (w *CacheWorker) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case CacheConnectionLost:
health.SignalDown(w, gen.Atom("health"), "cache")
// Pod removed from service but not restarted
case CacheConnectionRestored:
health.SignalUp(w, gen.Atom("health"), "cache")
// Pod added back to service
}
return nil
}import (
"ergo.services/ergo/app"
"ergo.services/ergo/gen"
)
type MyApp struct {
app.Application
db *sql.DB
}
func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
a.Log().Info("loading config")
return gen.ApplicationSpec{
Name: "myapp",
Group: []gen.ApplicationMemberSpec{
{Name: "worker", Factory: createWorker},
{Factory: createSupervisor},
},
Mode: gen.ApplicationModeTransient,
}, nil
}type ApplicationBehavior interface {
PreLoad(app Application, args ...any) (ApplicationSpec, error) // do not override
Load(args ...any) (ApplicationSpec, error) // implement this
Init(ref Ref, mode ApplicationMode) error // optional, pre-start
Start(ref Ref, mode ApplicationMode) // optional, post-start
Stop(ref Ref, reason error) // optional, pre-stop
Terminate(reason error) // optional, post-stop
}gen.ApplicationSpec{
InitTimeout: 10 * time.Second,
StartTimeout: 5 * time.Second,
StopTimeout: 10 * time.Second,
}type MyApp struct {
app.Application
db *sql.DB
}
func (a *MyApp) Init(ref gen.Ref, mode gen.ApplicationMode) error {
db, err := sql.Open("postgres", dsn)
if err != nil {
return err // the start aborts and Terminate is not called
}
if err := db.Ping(); err != nil {
db.Close()
return err
}
a.db = db
return nil
}
func (a *MyApp) DB() *sql.DB { return a.db }
func (a *MyApp) Terminate(reason error) {
a.db.Close()
}func (w *Worker) Init(args ...any) error {
w.db = w.Application().Behavior().(*MyApp).DB()
return nil
}gen.ApplicationSpec{
Name: "myapp",
Network: gen.ApplicationNetwork{
RegisterTypes: []any{Order{}, Customer{}},
RegisterErrors: []error{ErrInvalidOrder},
RegisterAtoms: []gen.Atom{"my_atom"},
},
}func (w *Worker) HandleMessage(from gen.PID, msg any) error {
app := w.Application()
if app == nil {
return nil // not running under any application
}
if w.isReady() {
app.AddTag("ready")
}
return nil
}func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "api_service",
Tags: []gen.Atom{"blue", "v2.1.0"},
// ... rest of spec
}, nil
}// inside MyApp callback
a.AddTag("ready") // mark instance ready, push update to registrar
// inside a process within the application
func (w *Worker) HandleMessage(from gen.PID, msg any) error {
if w.degraded() {
w.Application().AddTag("degraded")
}
return nil
}// Query the registrar for all instances
routes, err := network.ResolveApplication("api_service")
// routes is gen.ApplicationRoutes: a slice of ApplicationRoute with
// chainable filter methods.
// Filter: only blue, ready, in Running state, not draining
selected := routes.
WithTags("blue", "ready").
WithoutTags("draining").
WithState(gen.ApplicationStateRunning)
for _, route := range selected {
remoteNode, _ := network.GetNode(route.Node)
info, _ := remoteNode.ApplicationInfo("api_service")
// Use this instance
}func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "backend",
Map: map[string]gen.Atom{
"api": "api_server_v2",
"db": "postgres_pool",
"cache": "redis_manager",
},
Group: []gen.ApplicationMemberSpec{
{Name: "api_server_v2", Factory: createAPI},
{Name: "postgres_pool", Factory: createDB},
{Name: "redis_manager", Factory: createCache},
},
}, nil
}// Query application info (works locally or remotely)
info, err := node.ApplicationInfo("backend")
// or: info, err := remoteNode.ApplicationInfo("backend")
// Find process name by role
apiName, found := info.Map["api"]
if found {
// Use the actual process name to communicate
response, err := node.Call(apiName, APIRequest{})
}package orders
const name gen.Atom = "orders" // registered process name, private to this package
// message types are internal - callers never see or construct them
type messagePlace struct{ Item string; Qty int }
type statusRequest struct{ ID string }
type statusResponse struct{ Status OrderStatus }
// Place is fire-and-forget, so it wraps a Send to the local instance.
func Place(process gen.Process, item string, qty int) error {
return process.Send(name, messagePlace{Item: item, Qty: qty})
}
// Status needs a reply, so it wraps a Call.
func Status(process gen.Process, id string) (OrderStatus, error) {
result, err := process.Call(name, statusRequest{ID: id})
if err != nil {
return OrderStatus{}, err
}
return result.(statusResponse).Status, nil
}// before
type MyApp struct{}
func (a *MyApp) Load(node gen.Node, args ...any) (gen.ApplicationSpec, error) { ... }
func (a *MyApp) Start(mode gen.ApplicationMode) {}
func (a *MyApp) Terminate(reason error) {}
// after
type MyApp struct {
app.Application
}
func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) { ... }UI
/
The bundle built into the binary. Only one listener may serve it.
API
/sse, /api/*
Two paths under /api/ are deliberately open, because both have to work before a caller is authenticated: GET /api/capabilities, which is how a client discovers what this endpoint offers, and POST /api/enroll, which is the exchange that authenticates it in the first place. Both are rate-limited, enroll on its own hard limit, and neither reveals anything about the node. Everything else under /api/ goes through the authorizer.
The default configuration serves all three surfaces on localhost:9911. That is the right shape for a development machine and the wrong one for a deployment other people can reach, which is what the rest of this page is about.
observer.CreateApp accepts observer.Options:
Host
localhost
Interface to bind. Belongs to a Listener once Listeners is set.
Port
9911
The single-listener fields (Host, Port, Authorizer, RateLimit, AllowedOrigins) are a shorthand for one entry in Listeners. Setting any of them together with Listeners is refused at start, with a message naming those five - they are not silently ignored, and not merged.
One entry per endpoint. This is how the same node offers a full-access UI to an operator on loopback and a read-only API to something else, without either being able to reach the other's policy:
Name
:<port>
Goes into the start log and into HandleInspect.
Host
localhost
Zero means the default for MaxStreams and MaxSubscriptions, not "no limit". For RateLimit zero does mean no limit: a missing rate limit is safe, a missing stream limit is not.
The start log states what each listener ended up serving, which is worth reading once after a configuration change:
The local listener reads narrowed rather than full because the deployment Ceiling above denies manage.kill, and a deployment ceiling applies to every listener. full appears only when nothing above the listener has narrowed anything.
A ceiling is the limit of what a caller may ask for, written as capability names. Every level carries one and every level can only narrow: the deployment ceiling bounds the listener, the listener bounds the surface, and the authorizer bounds the caller.
Capability names come in two planes. Everything under inspect. reads, everything under manage. changes the node, and ReadOnly is exactly "refuse the manage. plane".
inspect. — capabilities, node, node_short, network, connection, connection_list, process_list, process_range, process, process_state, process_lookup, meta, meta_state, app_tree, subtree, application_list, event_list, event, event_stream, log, tracing, goroutines, heap_profile, types, errors, atoms, cron_info, cron_schedule, registrar_nodes, registrar_routes, registrar_proxy_routes, registrar_application_routes
manage. — send, send_meta, send_exit, send_exit_meta, kill, set_log_level, set_process_log_level, set_meta_log_level, set_node_tracing_sampler, set_process_tracing_sampler, set_process_send_priority, set_process_compression, set_process_compression_type, set_process_compression_level, set_process_compression_threshold, set_process_keep_network_order, set_process_important_delivery, set_meta_send_priority, app_start, app_stop, app_unload
Two details of Allow and Nodes decide what an empty slice means. Unset (nil) does not narrow. Present and empty permits nothing. That distinction is what makes composition work: narrowing two allowlists intersects them, and two lists with nothing in common intersect to empty rather than to "no restriction".
Narrow is the composition the observer applies at every level, and it is exported if you compose ceilings yourself:
The UI reads its own ceiling and hides what it cannot use, so an operator on a read-only listener sees no kill buttons rather than buttons that fail.
Without an authorizer a listener is open: everyone who reaches it gets the listener's ceiling. That is fine for localhost on a development machine and nothing else.
An authorizer answers who the caller is:
It runs on the web server goroutine, before the request reaches an actor, so it must not block for long. Returning access.ErrUnauthenticated answers 401, access.ErrForbidden answers 403, and any other error answers 403. No detail from the error reaches the caller.
Subject is more than a label: it scopes what belongs to whom. A cluster run started by one caller is readable and cancellable only by that caller, and a keyed resource cursor belongs to the caller that asked for it. Ownership is the tenant and the subject together, so two callers share a run only when both fields match - the same subject under a different tenant is a different owner.
Observer ships one implementation, for the common deployment where a proxy in front has already authenticated the request:
TrustedHeader verifies nothing. Its whole security is that nothing but the proxy can reach the listener, which is why the node refuses to start when such a listener binds a non-loopback address. State ReachableOnlyByProxy: true if the path is restricted some other way, and own that claim.
A caller in several listed groups gets the wider of their ceilings, and that is why the groups must be comparable: the node refuses to start if two of them are ceilings where neither contains the other, since a caller holding both would end up with more than either grants. That is the reason viewer above repeats the manage.kill deny it already gets from ReadOnly - without it, read-only and "everything but kill" are two different shapes rather than one inside the other. A caller in no listed group is refused.
A browser sends an Origin header, and the observer refuses a request from an origin it does not allow. The page a listener served itself is always allowed, so a same-origin bundle needs no configuration.
DefaultAllowedOrigins is added to every listener:
Set it to nil before starting the application to allow nothing but what each listener names. Per-listener entries are added to it, not put in its place.
Each entry is scheme://host[:port] with no path. A port of * matches any port, a leftmost-label wildcard (https://*.example.com) matches one label and not the parent, and * alone means any origin without credentials. Anything else fails the start rather than silently blocking every cross-origin call.
Behind a proxy that terminates TLS the page comes back over https while the request reaches the observer as plain http. The observer reads X-Forwarded-Proto for exactly that, so a bundle served by an ingress under its own hostname is treated as same-origin without that hostname being configured anywhere.
SurfaceMCP configures what an agent gets:
Disable
served
Stops serving /mcp on this listener.
Ceiling
the listener's
Ceiling separates surfaces, not callers: whoever reaches this listener reaches both surfaces, so narrow API as well or put the surfaces on listeners of their own.
Instructions is the one place to say what no amount of inspection reveals: which node runs which part of the business, where a flow begins, what not to touch. It is added to the guidance the observer gives about itself rather than replacing it, so navigating the surface stays described whatever you write:
CacheTTL matters during development and nowhere else. Inside one process none of those listings change, so a long value costs nothing in a running deployment. It costs when the binary is rebuilt: a client outlives the restart, and until the TTL expires it calls tools with the arguments of the binary it first met. Set it to a few seconds where you rebuild often.
The cluster map is the observer's own view of every node it can see, kept current by watching them. It is what an agent reads as ergo://cluster.
WatchLimit
5000
Nodes being watched. Nodes discovered beyond it stay on the map without data.
Concurrency
64
Enrollment serves POST /api/enroll, a one-time exchange that lets the cloud confirm the endpoint it was given really is this observer:
The token burns on the first success and the endpoint answers 410 after that. A wrong token is a 403, counted in the manager's HandleInspect, and rate limited on its own. An empty Token means the endpoint is not served at all.
Observer is a normal application on the node it runs on, and most of what it does is cheap: reading counters the framework already maintains. Three things are not.
A goroutine dump and a heap profile stop the world for as long as the walk takes, on the node being inspected. They are worth asking for, and worth asking for once.
An open stream is not free: each /sse or /mcp stream holds a goroutine and an actor for its whole life, which is what MaxStreams bounds, and an /sse stream also holds a gzip writer, since the UI stream is compressed and the agent stream is not. Subscriptions inside a stream cost a producer on the observed node, which is what MaxSubscriptions bounds.
A cluster run holds a pool of workers until it finishes or expires, which is what JobLimit and JobMaxRetention bound. Those limits exist so that one careless client cannot leave work behind on the node.
Every observer process implements HandleInspect, so the observer is visible in the observer. The web process reports its listener name, address, TLS, surfaces, authorizer, ceiling, origins and how many were refused, rate limit, open streams against the limit, refusals by status code, whether enrollment is configured, and uptime. Read it from the UI, from another process with Inspect(pid), or with the process_state tool over MCP.
A development machine. observer.Options{}. Loopback, no authorizer, everything permitted, all three surfaces. Nothing else is needed, and nothing else is safe.
Behind an authenticating proxy. One listener on 127.0.0.1 with access.TrustedHeader and per-group ceilings, the proxy in front of it, and a deployment ceiling denying whatever nobody should have. The UI, the API and MCP all inherit the caller's identity, so an operator and their agent are bounded the same way.
A cloud-facing read-only endpoint. A second listener with Ceiling{ReadOnly: true}, UI disabled because the cloud serves its own bundle, AllowedOrigins naming that bundle's origin, and a rate limit. The first listener keeps serving the local UI at full access.
Inspecting With Observer - the tour of the web UI
Inspecting With an AI Agent - the MCP surface in practice
Inspecting Actor State - what your own actors expose to all of this
CertManager - serving a listener over TLS
import (
"ergo.services/ergo"
"ergo.services/application/observer"
"ergo.services/ergo/gen"
)
func main() {
options := gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
observer.CreateApp(observer.Options{}),
},
}
node, err := ergo.StartNode("mynode@localhost", options)
if err != nil {
panic(err)
}
node.Wait()
}observer.CreateApp(observer.Options{
Ceiling: observer.Ceiling{Deny: []string{"manage.kill"}},
Listeners: []observer.Listener{
{
Name: "local",
Port: 9911,
},
{
Name: "public",
Port: 9912,
Ceiling: observer.Ceiling{ReadOnly: true},
UI: observer.SurfaceUI{Disable: true},
MCP: observer.SurfaceMCP{Disable: true},
AllowedOrigins: []string{"https://ops.example.com"},
RateLimit: 50,
},
},
})listener "local" localhost:9911 surfaces=api,ui,mcp authorizer=no ceiling=narrowed origins=5 ratelimit=0
listener "public" localhost:9912 surfaces=api authorizer=no ceiling=read-only origins=6 ratelimit=50observer.Ceiling{
ReadOnly: true, // refuse every mutating capability
Allow: []string{"inspect.process_list"}, // unset does not narrow; empty permits nothing
Deny: []string{"manage.kill"}, // wins over Allow
Nodes: []string{"orders@host"}, // unset does not narrow; empty permits no node
}import "ergo.services/application/observer/access"
// never wider than either argument: ReadOnly spreads, Deny accumulates,
// non-empty lists intersect
bounded := access.Narrow(deployment, perCaller)type Authorizer interface {
Authorize(request *http.Request) (Identity, error)
}
type Identity struct {
Subject string // a user id, a service account, a token subject
Tenant string // groups subjects that share a scope; empty when the deployment has one
Ceiling Ceiling // narrows the listener's ceiling for this caller
}import "ergo.services/application/observer/access"
observer.Listener{
Name: "team",
Host: "127.0.0.1", // only the proxy can reach it
Port: 9913,
Authorizer: access.TrustedHeader{
Subject: "X-Auth-Request-Email",
Tenant: "X-Auth-Request-Domain",
Groups: "X-Auth-Request-Groups",
Ceilings: map[string]observer.Ceiling{
"viewer": {ReadOnly: true, Deny: []string{"manage.kill"}},
"sre": {Deny: []string{"manage.kill"}},
},
},
}var DefaultAllowedOrigins = []string{
"http://localhost:*", // any port, so a dev server reaches it
"http://127.0.0.1:*",
"http://[::1]:*",
"https://ergo.observer",
"https://app.ergo.observer",
}MCP: observer.SurfaceMCP{
Instructions: "Orders begin at orders@* and settle through billing@*. " +
"The nodes named archive@* are cold storage: read them, never touch them.",
},Enrollment: observer.EnrollmentOptions{
Token: os.Getenv("OBSERVER_ENROLL_TOKEN"),
ClusterID: "orders-prod",
}http://localhost:8888/dashboardsThe tour goes from the outside in. First how to move around the interface at all, then the node at a glance, its applications, and finding the one process that matters among thousands. Then everything about that process and how to act on it. After that come the specialized views, each answering a specific question: how messages flow, how the node is connected, what the logs say, where memory and time go, and how a single request travels across the system. Then how to point any of these views at any node in the cluster, and last, the same data read by an AI agent rather than by you.
Screenshots are collapsed. Open the one you need; the page stays light if you do not.
Everything lives behind a sidebar with eight pages, and the page you are on is only half the interface. The other half is floating windows: click a PID, an event, a connection, or an application anywhere in the UI and it opens in its own window on top of the page. Windows drag, resize, minimize, and maximize on a double-click of the title bar. Several can be open at once and they survive page switches, so you can keep a suspect process in view while you read the log or a trace somewhere else.
The sidebar lists every open window below the navigation, so you always know what you have open even when a window is minimized or buried. A window whose subject is gone (the process terminated, the connection dropped) stays open with its name struck through and marked "gone", so a disappearance is something you notice rather than something that silently vanishes. One button minimizes them all.
Each window has a copy link action. The link encodes the node and the window, so pasting it into a chat gives a colleague the same process detail window on the same node, not just the page it was on. Opening such a link switches Observer to the right node first, then reopens the window. Links exist for processes, meta processes, connections, applications (optionally at a subtree root), and event streams.
The top bar carries the node selector on the left and the connection indicator on the right. The indicator is not decoration: it reports whether the stream is actually live, and clicking it opens the traffic counters and the list of active subscriptions, which is how you tell "nothing is happening" apart from "the stream is dead". A theme toggle and an About dialog sit at the bottom of the sidebar, which also collapses to icons when you need the width.
The Info page answers the first question you ask: is this node healthy, and what is it running? Three glance cards and three live charts across the top track the numbers that move: process count split into total, running and zombie; event throughput published against received; memory used against the GOMEMLIMIT ceiling; and per-second CPU split into user and system time. A climbing memory line or a growing process count shows up here first.
Below the glance, Node identifies it (name, CRC32, framework version, operating system and architecture), Node Runtime carries the figures the Go runtime reports (goroutines, live heap, the next GC threshold, GC cycles and the CPU fraction spent collecting), and Node Config holds two controls that take effect immediately: the log level, and the tracing sampler that decides whether the node starts new traces for messages sent through node.Send() and node.Call(). Turn the sampler up to investigate, and back off when you are done.
The rest of the page summarizes the node without leaving it. Registry counts registered names, aliases and events. Delivery Errors splits failures four ways, send and call, local and remote, which separates "the target is gone" from "the network is broken". Events shows published, received, local sent and remote sent. Loggers shows the log volume per level beside the loggers currently registered, and Tracing counts spans by kind (send, call, request, response, spawn, terminate) beside the registered exporters. Both say plainly when nothing is registered.
An Ergo node runs its work as applications, each a supervised group of processes. A strip across the top counts them by state (total, running, transitioning, loaded) and every application is a card, and the card is a summary of the contract it declared: its state (loaded, running, stopping) and mode, its description, version, parent and uptime, whether it depends on the network, and the applications it depends on. Two fields feed service discovery: its weight, the priority the registrar applies when several nodes offer the same application, and its tags, the labels a caller selects on (see Tags for Instance Selection).
Two numbers on the card are easy to confuse and worth separating. Members is how many processes the application declared in its group. Processes is how many exist in its tree right now, the members plus everything they spawned, and the bar draws that as a share of the whole node. An application declaring four members and owning twelve hundred processes is not a contradiction; it is a supervisor tree that grew, and the bar tells you how much of the node it accounts for.
The card also shows the role map when the application defines one, which decouples logical roles from process names (see Process Role Mapping), and the application environment when it carries any.
The lifecycle controls sit on the card: start in a chosen mode, stop, force-stop when a graceful stop will not complete, or unload. These are the coarse controls; when something is wrong at the application level, this is where you intervene. System applications are protected from all four.
A running application opens its process tree in a floating window, and the tree is more than a diagram.
Colour carries a signal you choose. Kind paints each node by what the process is, which the framework reports for its own behaviors and your actors can opt into (see the Kind column below). The other five modes are heatmaps: mailbox depth, mailbox latency, utilization (the share of a process's lifetime spent inside callbacks), throughput, and state. An entire application lights up by the signal you picked, so a hot or backlogged branch becomes obvious at a glance. Each mode has a "how to read" explanation next to the selector, so the colour scale is never a guess.
The tree is navigable rather than static. Search by name or PID and step through matches with Enter and Shift+Enter, zoom in and out or fit the whole tree to the window, collapse branches beyond a depth you set, and adjust node width and label truncation when names are long. Any node can be opened as a subtree, either in place or in a new window, and a control takes you back to the application root. The node cap bounds how much is fetched at once, and the tree refreshes either on demand or on an interval you choose. Display options are remembered across sessions; per-window ones reset when the window closes.
The processes page is where you spend most of your time. Every process on the node appears in a table that refreshes every second, and the columns are less a list of facts than a set of signals you learn to read.
Mailbox depth turning yellow then red is a backlog forming: messages are arriving faster than the process handles them.
A state stuck in "running" for tens of seconds is a process blocked inside a handler, usually on something it should not be doing there.
High running time relative to uptime marks a hot process that spends its life executing handlers rather than waiting.
A green delta like "+42" next to Messages In shows who is active right now, in the last second.
Because the columns are signals, sorting turns the table into a diagnostic tool. Sort by Messages In to find the busiest processes, by Mailbox to bring the most backlogged to the top, by Running Time to see where handler time goes. Fourteen columns cover identification (PID, name, kind, behavior, application), messaging (messages in and out, mailbox depth, latency), and lifecycle (running time, init time, wakeups, uptime, state), which is enough to answer most questions without opening a single process.
Kind deserves a note. It classifies a process by what it is for rather than what type implements it: actor, supervisor, pool and router describe structure; fsm, saga, worker, scheduler, queue, producer, consumer and coordinator describe behavior; web, gateway, proxy, stream, broker and client sit on a boundary; store, cache and session hold data; metrics, leader, follower, health, monitor and logger are operational. The base behaviors report their own kind; your actors opt in either statically, by implementing ProcessKind(), or at runtime with SetProcessKind. Any string is valid, and anything outside the list renders as custom. Colour encodes the category and the icon the specific kind, the same way in the table and in the supervision tree; a reference gallery is one click away from either.
Three cards across the top summarize the node rather than the scope: process counters (total, spawned, terminated, spawn failures), a States bar showing how they split across running, sleep, wait and zombie, and delivery errors split local and remote. Clicking a segment of the States bar filters the table to it. Below them, three charts follow the current scope: messages in and out, the distribution of utilization, and mailbox latency. Click any PID to open a floating detail window.
A node can run tens of thousands of processes, so the table never shows all of them at once. The Scope panel decides what the node sends to the browser, and it works in two modes.
In the default mode you pick a window into the process list: "first 500" returns the 500 oldest processes, "last 500" the 500 newest, and entering a PID starts the window there. The node scans only that window, which stays fast no matter how many processes exist because it never looks beyond the range you asked for.
The "All" mode switches to a full scan: the node walks every process, applies your filters as it goes, and returns up to 10,000 matches. Because a full unfiltered scan could flood the browser, this mode requires at least one filter.
Filters narrow by name, behavior type, application, state, or minimum mailbox depth, and appear as removable chips in the toolbar. A separate search field adds quick matching over PID, name, behavior and application on top of the returned rows, for ad-hoc lookups without changing the scope.
Once the table points you at a suspect, the floating detail window tells you everything about it. A header line carries its state, PID, kind, behavior and application, four cards count messages in and out, mailbox depth and wakeups, and four tabs hold the detail.
The overview tab shows two live charts: incoming and outgoing message rates over the last minute, and the depths of the four mailbox queues (Main, System, Urgent, Log). Cards below show running time, init time, state time and uptime, the same signals as in the table but plotted over time. The parent and leader processes appear as links that open their own windows.
The relations tab reveals how the process is wired into the rest of the system: the aliases it registered, the meta processes it owns, the events it created, and its links and monitors grouped by type (see Links and Monitors). This answers a question that matters before you touch anything: who else is affected if this process terminates. Events registered on other nodes can be opened from here as remote event streams. From this tab you can also open the process's supervision subtree as a floating tree, with the same colour modes as the application tree, to see where it sits and which branch below it is busy.
The inspect tab shows whatever the process chooses to publish about itself through its HandleInspect callback, as live key-value pairs, refreshed on demand or on an interval you pick, with a filter over the keys. If your actor implements that method, it can surface any internal state you care about: queue lengths, cache sizes, open connection counts, the current step of a job. This is your own window into a process that the framework cannot see on its own. A process that implements nothing says so rather than showing an empty box.
The detail window is also where Observer stops being read-only.
The config tab changes settings that take effect on the running process immediately: raise its log level to get more detail from just that process, adjust message priority, compression (with its algorithm, level and threshold), important delivery and network ordering for its outgoing messages, or turn on the tracing sampler for a targeted look. The fallback setting shows where messages go when the mailbox overflows. The environment section appears when the node has ExposeEnvInfo enabled in its security settings.
Three icons in the window header intervene directly, from whichever tab you are on. Send Message delivers a message to the process. Send Exit sends an exit signal with a reason you choose, normal, shutdown or your own. Kill terminates it immediately. These act on the real system, so each asks for confirmation and names the target; they are disabled for system processes.
A meta process is not an actor with a mailbox loop; it is a goroutine the framework supervises on an actor's behalf, which is how a TCP connection, a web handler or a port stays attached to a process without blocking it. They appear on the relations tab of their owner and open into their own window.
That window is the actor window in miniature and for the same reasons: its two mailbox queues against their size limit, message counters in and out, uptime, the log level and message priority as live controls, and the meta process's own HandleInspect state. You can send it a message or an exit signal from there, which is how you close one connection out of thousands without touching the process that owns them.
Processes rarely work alone; they publish and subscribe to events. The events page makes that traffic visible.
Five cards across the top count the events registered and the messages published, received, delivered locally and sent to remote nodes. Below them, like the processes page, the table shows only what the scope defines. Each row names the event, its producer process, when it was registered, how many subscribers it has, and publication statistics, with delta indicators marking events that are actively publishing. The From control chooses First (oldest registered) or Last (newest), and filters narrow by name, notify mode, buffered mode, open mode, and minimum subscriber count. Three charts above the table summarize the scope: event throughput, event utilization, and how recently each event last published.
Beyond the table, you can open a live stream of a single event and watch the actual messages as the producer publishes them, in real time. Filters match the message type and its content, with an exclude mode to hide noise, so you can answer questions the counters cannot: what exactly is this producer emitting, and does it match what subscribers expect. The window states which of the two it is doing: observing a producer that publishes regardless, or acting as the first subscriber of an event whose producer is notified.
The network page shows how the node reaches the rest of the cluster and how much traffic flows.
The top of the page is configuration rather than telemetry. Parameters holds the network mode, max message size, handshake and protocol versions, and the negotiated flags. Registrar shows the service discovery backend, its endpoints, and which of its optional capabilities are available (proxy, application routes, config, events), so a node that cannot find its peers tells you here whether it even has a registrar to ask. Acceptors lists the node's listeners with their addresses, TLS configuration and per-acceptor flags. Below that, the page splits into five tabs.
The Connections tab is the default. Four live charts plot aggregate traffic across all connections: messages per second, bytes per second, compression operations, and fragmentation operations, each in and out. A connection table with its own scope shows every connection with delta indicators: direction, TLS or plain, node and connection uptimes, messages and bytes each way, pool size, reconnections and measured clock skew. Click a row for a detailed window. A cluster nodes section lists everything known through the registrar or an active connection, which is the quick picture of the topology.
The Routes tab shows configured static routes and proxy routes side by side: static routes tell the node where to dial when a name matches, proxy routes describe how to reach nodes through an intermediary. A node with neither says so, which is the expected state when discovery is doing the work.
The Types tab is a snapshot of the wire-format type registry, the set of message types the node knows how to serialize. Each row shows its registration ID, owning protocol version, kind, the wire size of a zero value, and canonical name; expand a row for the inferred Go shape of the type. Two filters narrow by name and by schema content. Refresh re-fetches the registry, which rarely changes after startup, so this tab does not stream. Errors and Atoms are the same idea for the other two registries: the error sentinels and the atoms this node knows how to put on the wire. With the node built using -tags=typestats, extra columns show per-type encode and decode counts and wire-byte totals; The average bytes per operation is what to sort on when picking compression candidates: a high average is worth compressing, a low one is not worth the framing overhead. See The typestats Tag.
Clicking a connection row opens a window with the full picture of one connection. Metric cards show messages and bytes in each direction. The identity section shows node and connection uptimes, framework and protocol versions, max message size, and the negotiated network flags as colored pills (Remote Spawn, Fragmentation, Important Delivery, and so on), each green when both nodes agreed to enable it. Below, the pool size and reconnection counter appear, and for outgoing connections the Pool DSN lists the addresses of the pooled TCP connections.
Two live charts track messages and bytes per second each way, and a proxy transit section adds a third when the connection carries traffic on behalf of other nodes. The compression and fragmentation sections show how many messages were compressed or fragmented, the ratios, the bytes saved and the reassembly timeouts, which tells you whether those features are helping or adding overhead. A "Switch observer to this node" button re-points Observer at the remote node.
The log page captures log messages in real time from every source on the node: processes, meta processes, the node itself, and the network stack.
Each entry shows a timestamp, a color-coded severity, the source, its registered name and behavior, and the message. The source column tells you where the message came from (a process PID, a meta-process alias, the node, or a network peer), and with the rich source toggle it becomes clickable, opening the detail window for whatever generated it. Long messages collapse and expand on click, structured fields appear as key=value pairs beneath the text, and any message can be copied on its own.
The Scope panel controls what the node captures, and the filtering happens on the server: disabling the debug level means the node stops collecting debug messages entirely, so filtering reduces load rather than just hiding rows. You can also match on source, behavior, field names and values, and message text, with an exclude mode to remove noise, and set the ring-buffer size with the limit.
The Play/Pause button stops the stream without disconnecting, so you can freeze the view and read what is there while the node keeps running. If the buffer fills and the node has to drop messages, a log storm warning appears with the suppressed count; raise the limit or narrow the scope if you see it often.
The profiler answers the two questions the process table cannot: why is memory growing, and why is something stuck. A GC Pressure section stays live at the top with four charts, allocation rate, dead rate, live ratio, and the fraction of CPU spent in garbage collection, so you can see memory pressure building before it becomes a problem. That section streams; the two tabs below it do not.
Both tabs work on a snapshot you ask for. Press Capture and the node profiles itself once, or set an interval and let it recapture on a schedule. Neither needs a restart or a special build flag. A heap capture costs the node a garbage collection, which is stated on the button, so it is cheap enough to use during an incident and not something to leave on a one-second timer.
The heap snapshot lists allocations sorted by in-use bytes, and can be re-sorted by in-use objects, allocated bytes, or allocated objects: the difference between "what is alive" and "what has been churned" is often the whole answer. For each entry you get the in-use and total bytes and objects, and the function responsible, meaning the first non-runtime function in the allocation stack. Expand a row for the full stack trace, or switch from the table to the flamegraph to see which call paths own the memory by area rather than by row. Filter by function name and by a minimum byte threshold to drop the noise.
Reach for this when memory grows unexpectedly: the stacks point straight at the code paths doing the allocating, and if one dominates the in-use bytes, that is where to start.
The goroutine snapshot groups goroutines by call stack, so 500 goroutines blocked on the same channel receive show up as one group of 500, with the state (running, chan receive, select, sleep, and so on), how long they have waited (green under a minute, yellow under five, red beyond), and where each was spawned versus where it is now. Expand a group for the full stack and goroutine IDs, or switch to the flamegraph view for the same snapshot arranged by stack. Filter by stack content, by state, and by a minimum wait time.
This is how you find deadlocks and blocking. Filter to "chan receive", search for a package name to isolate a specific actor, and a large group stuck for a long time in a state that should be brief usually points right at the problem.
The hardest question in a message-passing system is what actually happened when a request came in, because the work spreads across many processes and often many nodes. Tracing answers it. When tracing is on, Observer collects traces continuously, so data is already waiting when you open the page. For how tracing works, see Distributed Tracing.
Because Observer connects to one node at a time, it shows the observations recorded on that node. For a single trace stitched across the whole cluster, export to Pulse with Grafana Tempo or Jaeger.
Traces are listed newest first. Each row shows a copyable trace ID, the root process and the message that started the trace, an error marker if any span failed, the span count, a bar showing this trace's duration against the longest in view, and the total duration. The search field matches across trace and span fields alike (IDs, from, to, message text, attributes), Pause holds new traces back, and Clear empties the buffer.
Click a trace to expand its waterfall. It groups the observation points for each message (Sent, Delivered, Processed) into one row and arranges the rows into a tree by parent and child, so the indentation is the causal chain of who triggered whom.
Each row carries a color-coded kind (SEND, CALL, RESP, SPAWN, TERM, and SPAN for a business span opened with StartTracingSpan), the sender and receiver with their behaviors, the message type, and a timeline bar split into a lighter transit segment (Sent to Delivered) and a solid processing segment (Delivered to Processed). Hovering shows the node at each point and the exact durations. For a message that crosses nodes, the transit time subtracts the measured clock skew between them, so the number reflects real travel time rather than clock drift. Local PIDs are clickable and open detail windows, and clicking a row opens a panel with every field of the span and the custom attributes merged from all of its observation points.
The Scope panel toggles which span kinds (SEND, CALL, RESP, SPAWN, TERM) and observation points (Sent, Delivered, Processed) are collected, and a message pattern filter matches message type and error text with an optional exclude. The buffer limit sets how many traces are kept. Collecting fewer kinds is not just less noise on screen: the node stops recording what you switched off.
Everything above applies to one node, but you rarely run just one. The node selector in the top bar lists every node Observer can see through the registrar, searchable by name or by CRC32; pick one and all the views above re-point to it. Nothing needs to be deployed on the target: it already runs the system application that Observer talks to, and the list updates live as nodes join and leave the cluster. The node you are on is part of the URL, which is what makes a copied window link land on the right node.
You can also reach a node that Observer is not yet connected to. If the registrar knows it, selecting it is enough; otherwise you give its name, host, port and cookie, optionally over TLS, and Observer establishes the connection. The "Switch observer to this node" button on a connection detail window does the same for a peer you are already looking at. From a single browser tab, you move freely across the entire cluster.
The map of the whole cluster, every node at once with the traffic between them, is not part of this UI. It belongs to the cloud interface at ergo.observer, which reads the same observer over the same API. What the embedded UI gives you is one node at a time, with free movement between them.
Every view on this page is a layout somebody decided on in advance, which is what makes it fast to read and useless for a question nobody anticipated. The same observer serves a second surface for exactly those questions: an MCP endpoint where an AI agent reads the node as addressable resources and calls tools on demand.
It is the same data, the same node, and the same authorization: a read-only ceiling refuses an agent's kill for the same reason it hides yours. What differs is who chooses the next question. See Inspecting With an AI Agent.
git clone https://github.com/ergo-services/examples
cd examples/observability
make upUDP is fundamentally different from TCP. There are no connections, no ordering guarantees, no reliability. Datagrams arrive independently, potentially out of order, possibly duplicated, or lost entirely. This makes UDP simpler than TCP, but also requires different handling patterns.
Traditional UDP servers use blocking ReadFrom calls in loops. This doesn't fit the actor model's one-message-at-a-time processing. You could spawn goroutines to read packets, but this breaks actor isolation and requires manual synchronization.
UDP meta-process wraps the socket in an actor. It runs a read loop in the Start goroutine, sending each received datagram as a message to your actor. To send datagrams, you send messages to the UDP server's meta-process. The actor model stays intact while integrating with blocking UDP operations.
Unlike TCP, UDP has no connections. One meta-process handles the entire socket - all incoming datagrams from all remote addresses. There's no per-connection state, no connection lifecycle, no connect/disconnect messages. Just datagrams in, datagrams out.
Create a UDP server with meta.CreateUDPServer:
type DNSServer struct {
act.Actor
udpID gen.Alias
}
func (d *DNSServer) Init(args ...any) error {
options := meta.UDPServerOptions{
Host: "0.0.0.0",
Port: 53,
BufferSize: 512, // DNS messages are typically small
}
server, err := meta.CreateUDPServer(options)
if err != nil {
return fmt.Errorf("failed to create UDP server: %w", err)
}
udpID, err := d.SpawnMeta(server, gen.MetaOptions{})
if err != nil {
// Failed to spawn - close the socket
server.Terminate(err)
return fmt.Errorf("failed to spawn UDP server: %w", err)
}
d.udpID = udpID
d.Log().Info("DNS server listening on %s:%d (id: %s)",
options.Host, options.Port, udpID)
return nil
}The server opens a UDP socket and enters a read loop. For each received datagram, it sends MessageUDP to your actor. Your actor processes it and optionally sends a response by sending MessageUDP back to the server's meta-process ID.
If SpawnMeta fails, call server.Terminate(err) to close the socket. Without this, the port remains bound until the process exits.
The server runs forever, reading datagrams and forwarding them as messages. When the parent actor terminates, the server terminates too (cascading termination), closing the socket.
The UDP server sends MessageUDP for each received datagram:
MessageUDP contains:
ID: The UDP server's meta-process ID (same for all datagrams)
Addr: Remote address that sent this datagram (net.Addr - typically *net.UDPAddr)
Data: The datagram payload (up to BufferSize bytes)
To send a datagram, send MessageUDP to the server's ID with the destination address and payload. The server writes it to the socket with WriteTo. The ID field is ignored when sending (it's only used for incoming datagrams).
Unlike TCP:
No connect/disconnect messages - datagrams are independent
Addr changes for each datagram - track remote addresses yourself if needed
No message framing - each UDP datagram is a complete message
No ordering guarantees - process datagrams as they arrive
UDP has no connections. Each datagram is independent. The same remote address might send multiple datagrams, but there's no session state. If you need state per remote address, maintain it yourself:
Because UDP has no connection lifecycle, you need application-level timeout logic to clean up stale state. The server doesn't know when clients "disconnect" - they just stop sending datagrams.
By default, all datagrams go to the parent actor. For servers handling high datagram rates, this creates a bottleneck. Use Process to route to a different handler:
All datagrams go to metrics_collector instead of the parent. This enables separation of concerns - the actor that creates the UDP server doesn't need to handle datagrams.
Unlike TCP's ProcessPool, UDP only has a single Process field. You can route to an act.Pool:
Each datagram is forwarded to the pool, which distributes them across workers. This works for UDP because datagrams are independent - there's no per-connection state to corrupt. For TCP, ProcessPool uses round-robin to maintain connection-to-worker binding. For UDP, the pool can distribute freely.
Use pools when datagram processing is CPU-intensive or slow (database writes, external API calls). Workers process datagrams in parallel, maximizing throughput.
The UDP server allocates a buffer for each datagram read. By default, it allocates a new buffer every time, which becomes garbage after you process it. For high datagram rates, this causes GC pressure.
Use a buffer pool:
The server gets buffers from the pool when reading. When you receive MessageUDP, the Data field is a buffer from the pool. Return it to the pool after processing:
When you send MessageUDP to write a datagram, the server automatically returns the buffer to the pool after writing (if a pool is configured). Don't use the buffer after sending.
If you need to store data beyond the current message, copy it:
Buffer pools are essential for servers receiving thousands of datagrams per second. For low-volume servers (a few datagrams per second), the GC overhead is negligible - skip the pool for simplicity.
UDP datagrams are limited by the network's Maximum Transmission Unit (MTU). IPv4 networks typically have 1500-byte MTU, IPv6 has 1280-byte minimum. After subtracting IP and UDP headers (28 bytes for IPv4, 48 bytes for IPv6), you get:
IPv4 safe maximum: 1472 bytes (1500 - 28)
IPv6 safe maximum: 1232 bytes (1280 - 48)
Internet-safe maximum: 512 bytes (DNS requirement)
Datagrams larger than MTU are fragmented at the IP layer. Fragmented datagrams are reassembled by the receiving OS before ReadFrom returns. However, if any fragment is lost, the entire datagram is discarded - UDP reliability degrades.
The default BufferSize is 65000 bytes (close to UDP's theoretical maximum of 65507 bytes). This handles any UDP datagram, but it's wasteful if your protocol uses smaller messages:
If a datagram is larger than BufferSize, it's truncated - you receive only the first BufferSize bytes. The rest is discarded. Set BufferSize to the maximum expected datagram size for your protocol.
Smaller buffers reduce memory usage (important with buffer pools). Larger buffers avoid truncation but waste memory if datagrams are typically small.
Unlike TCP, UDP meta-process has no chunking support. UDP datagrams are atomic - each datagram is a complete message. There's no byte stream to split or reassemble. The protocol boundary is the datagram boundary.
If your protocol sends multi-datagram messages, you must handle reassembly yourself:
UDP delivers datagrams out of order. Fragment 2 might arrive before fragment 1. Your reassembly logic must handle this. Use sequence numbers, timeouts for incomplete sets, and protection against memory exhaustion (limit maximum incomplete messages).
Most UDP protocols avoid multi-datagram messages entirely. Keep messages under MTU size for reliability and simplicity.
UDP datagrams can be:
Lost: Network congestion, router overload, buffer overflow
Duplicated: Network retransmission, switch mirroring
Reordered: Different paths through the network
Corrupted: Rare, but possible despite checksums
Design your protocol to handle these:
Loss tolerance: Don't rely on every datagram arriving. Either accept loss (game state updates, sensor readings) or implement application-level acknowledgment and retransmission.
Duplicate tolerance: Process datagrams idempotently. If the same datagram arrives twice, the result is the same. Use sequence numbers to detect and discard duplicates:
Reordering tolerance: Don't assume datagrams arrive in send order. Use timestamps or sequence numbers to handle reordering:
Corruption detection: UDP has a 16-bit checksum, but it's weak. Critical data should have application-level integrity checks (CRC32, hash, signature).
Most importantly: design your protocol so datagram loss doesn't break functionality. UDP is for scenarios where loss is acceptable (real-time updates) or where you implement your own reliability layer (QUIC, custom protocols).
UDP server supports inspection for debugging:
A meta process is inspected by its alias through InspectMeta, not by a Call: the request goes down the meta's system queue to its HandleInspect, which a Call never reaches. gen.Node carries the same method for callers that are not processes.
Use this for monitoring datagram counts, bandwidth usage, or displaying server status.
Pattern: Metrics aggregation
Aggregate many datagrams into periodic summaries. Lossy protocols (like StatsD) rely on volume - losing a few datagrams doesn't affect aggregate accuracy.
Pattern: Request-response with timeout
Implement application-level reliability with timeouts and retries. UDP doesn't guarantee delivery, so you must detect and handle failures.
Pattern: Broadcast responder
Respond to broadcast discovery requests. Track sender address from MessageUDP.Addr and reply directly.
Pitfall: Not returning buffers
Pool buffers are reused immediately. Storing them leads to data corruption when the pool reuses the buffer for the next datagram.
Pitfall: Assuming reliability
Some chunks will be lost. The server waits forever for missing chunks, or processes incomplete data. Either accept loss (send redundant data) or implement acknowledgment and retransmission.
Pitfall: Large datagrams
IP-level fragmentation significantly increases loss probability. If any fragment is lost, the entire datagram is discarded. Keep datagrams under 1472 bytes for reliability, or 512 bytes for internet-wide compatibility.
Pitfall: Not handling duplicates
Network equipment can duplicate UDP datagrams (switch mirroring, retransmission logic). Process commands idempotently or track sequence numbers.
UDP meta-process handles the complexity of socket I/O and datagram delivery while maintaining actor isolation. Design your protocol for UDP's unreliable, unordered, connectionless nature - and leverage its simplicity and low latency where reliability isn't critical.
How nodes find each other and establish connections
Service discovery solves a fundamental problem in distributed systems: how does one node find another node when all it has is a name?
When you send a message to a remote process, the target identifier contains the node name - a gen.PID includes the node where that process runs, a gen.ProcessID specifies both process name and node, and a gen.Alias includes the node. But what does that node name mean in network terms? What IP address? What port? Is TLS required? What protocol versions are supported? Service discovery answers these questions, translating logical node names into concrete connection parameters.
Consider a simple scenario. Node A wants to send to a process on node B. The process has a gen.PID that includes the node name "worker-node@server-cluster.local". That's the logical address, but it's not enough to open a TCP connection. The node needs to translate that into connection parameters:
What the bundle calls: the event stream and the request endpoints.
MCP
/mcp
The agent-facing surface: ergo:// resources and tools.
Port to bind. Belongs to a Listener once Listeners is set.
PoolSize
25
Workers handling POST requests.
Ceiling
zero, everything permitted
The limit of the whole deployment. A listener, and then a caller, can only be given less.
Authorizer
none, the listener is open
Identifies the caller. Belongs to a Listener once Listeners is set.
RateLimit
0, no limit
Requests per second one caller may make. Belongs to a Listener once Listeners is set.
AllowedOrigins
see Origins
Browser origins allowed on top of DefaultAllowedOrigins. Belongs to a Listener once Listeners is set.
Listeners
one listener from Host:Port
Runs one endpoint per entry, each with its own authorization. Host and Port must then be left unset.
Enrollment
empty, /api/enroll is not served
The one-time secret the cloud presents to confirm this endpoint is this observer.
JobMaxRetention
5m
The longest a finished cluster run may keep its result.
JobLimit
32
How many runs the observer holds at once, over every caller.
ClusterLens
see Cluster lens
The map of the cluster and the watchers keeping it current.
LogLevel
the node's
Log level of the observer's own processes.
Interface to bind.
Port
required
Port to bind, unique among the listeners.
CertManager
nil, plain HTTP
Serves this listener over TLS. See CertManager.
UI
served
SurfaceUI{Disable: bool}. At most one listener may serve the UI.
API
served
SurfaceAPI{Ceiling *Ceiling}. No switch of its own: /sse and /api/* are what a listener is for.
MCP
served
SurfaceMCP{Disable, Ceiling, Instructions, CacheTTL}, see The MCP surface.
Authorizer
none, the listener is open
Identifies the caller arriving here.
Ceiling
zero
Narrows the deployment ceiling for everyone arriving here.
RateLimit
0, no limit
Requests per second per caller: by subject when there is an authorizer, by address otherwise. The static bundle is not metered.
MaxStreams
64
Streams open here at once, /sse and /mcp together.
MaxSubscriptions
128
Live subscriptions one stream may hold. It also sizes the stream's mailbox.
AllowedOrigins
see Origins
Browser origins allowed here, on top of DefaultAllowedOrigins.
Narrows the listener ceiling for this surface alone.
Instructions
none
What an agent is told about this cluster before it asks anything.
CacheTTL
5m
How long a client may keep the tool and resource listings, and the discovery answer.
Nodes being connected at once.
WatchPeriod
3s
Interval between the snapshots a watched node publishes. Larger clusters want a larger value: every node sends one snapshot per period.
ReconcilePeriod
1m
Interval between the passes that check the membership bookkeeping. Nodes are dropped as they run out of support, not on this timer, so it is a backstop and wants a large value.
GracePeriod
1m
How long the last known peer list of an unreachable node still counts as evidence that its peers exist. Until it expires, a network split does not erase the far side of the map.
LastReadingPeriod
GracePeriod
How long the last reading of a node that went away stays on the map, marked stale.
Wakeups. How many times the process was activated to handle messages. Each activation processes one batch from the mailbox. A high wakeup count with low message counts can indicate many small deliveries.
Notify. Whether the producer receives notifications (MessageEventStart/MessageEventStop) when the first subscriber arrives or the last subscriber leaves.























The IP address or hostname to connect to
The port number where node B is listening
Whether TLS is required for this connection
Which handshake and protocol versions node B supports
Which acceptor to use if node B has multiple listeners
This information changes dynamically. Nodes start and stop. Ports change. TLS gets enabled or disabled. You don't want to hardcode these details into your application. You want discovery to happen automatically, and you want it to stay current.
Every node includes a registrar component that handles discovery. When a node starts, its registrar attempts to become a server by binding to port 4499 - TCP on localhost:4499 for registration and UDP on 0.0.0.0:4499 for resolution. If the TCP bind succeeds, the registrar runs in server mode. If the port is already taken (another node is using it), the registrar switches to client mode and connects to the existing server.
This design means one node per host acts as the discovery server for all other nodes on that host. Whichever node started first becomes the server. The rest are clients.
When a node's registrar runs in server mode, it:
Listens on TCP localhost:4499 for registration from same-host nodes
Listens on UDP 0.0.0.0:4499 (all interfaces) for resolution queries from any host
Maintains a registry of which nodes are running and how to reach them
Responds to queries with current connection information
Answers node listing queries with the nodes registered on it
Pushes membership changes to its registered clients over their registration links
When a node's registrar runs in client mode, it:
Connects via TCP to the local registrar server at localhost:4499
Forwards its own registration to the server over TCP
Performs discovery queries via UDP (to localhost for same-host, to remote hosts for cross-host)
Maintains the TCP connection until termination (for registration keepalive)
Receives membership changes pushed by the server over that same connection
This dual-mode design provides automatic failover. If the server node terminates, its TCP connections close. The remaining nodes detect the disconnection, and they race to bind port 4499. The winner becomes the new server. The others reconnect as clients. Discovery continues without manual intervention.
Registrar.Nodes() returns the other nodes the registrar knows about, excluding the node itself. For the embedded registrar the answer covers the nodes registered on this host plus the nodes registered on the hosts of the peers this node is connected with - one UDP query per known host, cached for a few seconds. A host nobody in the cluster talks to stays invisible: the embedded registrar keeps no state shared between hosts.
Registrar.Event() returns an event carrying gen.MessageRegistrarNodeJoined and gen.MessageRegistrarNodeLeft. For the embedded registrar the scope is this host, because registration is accepted over loopback only - a node learns immediately about nodes appearing and leaving on its own machine, and learns about the rest through Nodes() and through the peer lists of the nodes it already knows.
Central registrars answer both with cluster-wide scope: etcd and Saturn keep a mirror of the whole registry, so Nodes() returns every node and the event reports every join and leave. This difference in scope is worth designing around - code that must see the entire cluster should combine the event with a periodic Nodes() call rather than relying on notifications alone.
When a node starts, it registers with the registrar. This registration happens over the TCP connection (for same-host nodes) or through initial discovery queries (for the server itself).
What gets registered:
Node name (must be unique on the host)
List of acceptors this node is running
For each acceptor: port number, handshake version, protocol version, TLS flag
The TCP connection from client to server stays open, and it serves two purposes. It maintains the registration - if the connection drops, the node is considered dead. And the server pushes membership changes down it: when a node joins or leaves, every other registration link is told, and the receiving client re-emits that as gen.MessageRegistrarNodeJoined or gen.MessageRegistrarNodeLeft on the node's core event. Subscribe to those rather than polling if you want to react to a peer appearing or going away.
If a node tries to register a name that's already taken, the registrar returns gen.ErrTaken. Node names must be unique within a host. Across hosts, the same name is fine - node names include the hostname for disambiguation.
When a node needs to connect to a remote node, it queries the registrar for connection information.
The resolution mechanism depends on whether the querying node is running the registrar in server mode:
If the node runs the registrar server and the target is on the same host, resolution is a direct function call - no network involved. The server looks up the target in its local registry and returns the acceptor information immediately.
If the node is a registrar client, resolution uses UDP regardless of whether the target is same-host or cross-host. The node extracts the hostname from the target node name (worker@otherhost becomes otherhost), sends a UDP packet to that host on port 4499, and waits for a response. For same-host queries, this means UDP to localhost:4499. For cross-host queries, it's UDP to the remote host. The registrar server (wherever it is) looks up the node and sends back the acceptor list via UDP reply.
This UDP-based resolution is stateless. No connection is maintained, and each query is independent, which keeps it lightweight. Resolution itself is pull-only: a UDP answer tells you where a node listens now and nothing about later. Membership changes do arrive without asking, but over the TCP registration link rather than this path - as the joined and left events described above.
The resolution response includes everything needed to establish a connection:
Acceptor port number
Handshake protocol version
Network protocol version
TLS flag (whether encryption is required)
Multiple acceptors are supported. If a node has three acceptors listening on different ports with different configurations, all three appear in the resolution response. The connecting node tries them in order until one succeeds.
Central registrars (etcd and Saturn) provide application discovery - finding which nodes in your cluster are running specific applications. The embedded registrar doesn't support this feature.
When an application starts on a node, it registers an application route with the registrar:
The registrar stores this deployment information. Other nodes can then discover where the application is running:
When you only need the routes, node.Network().ResolveApplication(name) is a shortcut for the same chain. It returns the same gen.ApplicationRoutes and reports the same error as Registrar() when no registrar is configured:
The response is a gen.ApplicationRoutes value (a slice of gen.ApplicationRoute with chainable filter methods). It includes the node name, application state, running mode, weight, tags, and the application version for each instance. Multiple nodes can run the same application; the resolver returns all of them.
The version comes from Version in the application's gen.ApplicationSpec and is registered with the route automatically, so during a rolling upgrade the two releases of the same application are distinguishable at resolve time without tagging them by hand:
Narrow the result by tag, state, or both:
Each filter returns a fresh ApplicationRoutes. The original slice is unchanged, so the same response can be filtered multiple ways.
Weights enable intelligent load distribution across application instances.
When multiple nodes run the same application, each registration includes a weight. Higher weights indicate preference - nodes with more resources, better performance, or strategic positioning get higher weights. When you resolve an application, you get all instances with their weights:
You choose which instance to use based on your load balancing strategy:
Weighted random - Randomly select, but favor higher weights. Worker3 gets picked 2x more often than worker1, 4x more than worker2.
Round-robin with weights - Cycle through instances, but send proportionally more requests to higher-weighted nodes. Send 4 requests to worker3, 2 to worker1, 1 to worker2, then repeat.
Least-loaded - Track active requests per instance, prefer higher-weight nodes when load is equal.
Geographic routing - Set weights based on proximity. Same datacenter gets weight 100, same region gets 50, cross-region gets 10.
The weight is advisory metadata, so you can implement any of the strategies above from the full list.
With etcd you usually do not need to: its ResolveApplication orders the instances by smooth weighted round-robin, so the one at index [0] is the weighted pick for that call. Take routes[0] each time and traffic is distributed by weight with no extra code.
Saturn does not do this. Its ResolveApplication filters out negative weights and returns the routes as it holds them, in no particular order and with no rotation state - so routes[0] is not a weighted pick there, and picking by weight is the caller's job. Do not write code that relies on the ordering unless you know which registrar is behind it.
Weight controls how often an instance is picked:
Higher weight is chosen proportionally more often. In the list above worker3 (200) wins roughly twice as often as worker1 (100) and four times as often as worker2 (50).
Lower positive weight is chosen proportionally less often, but stays in rotation.
Zero or unset counts as 1, so a forgotten weight never drops an instance from rotation.
Negative takes the instance out of rotation entirely: the resolver drops it from the results, so callers never see it.
Because weight is a dynamic field, a running instance can change its own weight, or take itself out of rotation and rejoin later, without unregistering:
Service mesh - Applications discover service endpoints dynamically. Your "api" application needs to send requests to the "workers" application. Instead of hardcoding which nodes run workers, you resolve it at runtime. When workers scale up or down, discovery reflects the current topology.
Job distribution - A scheduler needs to distribute jobs across worker nodes. Resolve the "workers" application, get the list of available instances with their weights, and distribute jobs proportionally. If a worker node goes down, the next resolution returns fewer instances automatically.
Application migration - You're moving an application from old nodes to new nodes. Start the application on new nodes with low weights. Verify it works correctly. Gradually increase weights on new nodes while decreasing weights on old nodes. Traffic shifts smoothly. Once migration completes, stop the application on old nodes.
Feature flags - Run experimental versions of an application on a subset of nodes with specific weights. Route a percentage of traffic to the experimental version. If it performs well, increase its weight. If it fails, remove its registration entirely.
Multi-region deployment - Deploy applications across regions. Use weights to prefer local regions. A node in us-east resolves the application and gets instances from all regions, but us-east instances have weight 100, us-west has weight 20, eu has weight 10. Most traffic stays local, but you can still route to other regions if needed.
Central registrars provide cluster-wide configuration storage. The embedded registrar doesn't support this - each node maintains its own configuration independently.
Configuration lives in the registrar's key-value store. For etcd, this is etcd's native key-value storage. For Saturn, it's stored in the Raft-replicated state. Any node can read configuration, creating a single source of truth for cluster settings:
Configuration values can be any type - strings, numbers, booleans, nested structures. The registrar encodes them using EDF, so complex configuration is supported.
Global configuration - Settings that apply cluster-wide. Database connection strings, external service URLs, feature flags. Store them in the registrar, and all nodes read the same values. When you update a configuration item in the registrar, new nodes get the updated value automatically.
Per-node configuration - Node-specific settings stored with the node name as a key prefix. Store node:worker1:cpu_limit, node:worker2:cpu_limit separately. Each node reads its own configuration using its name. This enables heterogeneous clusters where nodes have different capabilities.
Per-application configuration - Settings specific to an application. Store under an application key prefix: app:workers:batch_size, app:workers:concurrency. When the application starts on any node, it reads this configuration from the registrar.
Environment-based configuration - Different values for dev/staging/production. Use key prefixes: prod:database_url, staging:database_url, dev:database_url. Nodes set an environment variable indicating their environment and read the appropriate keys.
Configuration hierarchy - Combine multiple patterns with fallbacks. Read app:workers:batch_size, fall back to default:batch_size, fall back to hardcoded default. This provides specificity where needed and defaults everywhere else.
Configuration in the registrar is static from the framework's perspective - it doesn't push updates to running nodes. When you change a configuration item in etcd or Saturn, running nodes don't see the change automatically. They have the value they read during startup or their last query.
To implement dynamic configuration updates, use the registrar event system:
Both etcd and Saturn registrars support events and push notifications immediately when:
Configuration changes - EventConfigUpdate with item name and new value
Nodes join/leave - EventNodeJoined / EventNodeLeft with node name
Applications lifecycle - EventApplicationLoaded, EventApplicationStarted, EventApplicationStopping, EventApplicationStopped, EventApplicationUnloaded with application name, node, weight, and mode
Each registrar defines its own event types in its package (ergo.services/registrar/etcd or ergo.services/registrar/saturn). The event structures are identical, but you must use the correct package import for your registrar. This lets you react to cluster changes in real-time.
The embedded registrar supports events too, with a narrower scope: it reports nodes joining and leaving this host. See Listing Nodes and Membership Events.
With event notifications from etcd or Saturn registrars, nodes learn about configuration changes within milliseconds.
Database connection strings - Instead of deploying configuration files to every node, store the connection string in the registrar. Nodes read it on startup. When you rotate credentials or migrate to a new database, update the registrar. Restart nodes gradually, and they pick up the new connection string automatically. No configuration file deployment needed.
Feature flags - Enable or disable features dynamically across the cluster. Store feature:new_algorithm:enabled in the registrar. Applications check this flag when deciding which code path to use. Change the flag in the registrar, restart applications (or use events for live updates), and the feature rolls out cluster-wide.
Capacity planning - Store node capacity information: CPU limits, memory limits, concurrent job limits. Applications read these limits and respect them when distributing work. When you upgrade hardware, update the capacity values in the registrar. Applications discover the new capacity automatically.
Service discovery integration - Combine application discovery with configuration. Store connection parameters for each application deployment. When you resolve the "workers" application, you get not just the node names but also their specific configurations - which worker pool size, which queue they're processing, which priority level they handle.
Staged rollouts - Store configuration with version tags. Set config:version to "v2". Nodes read their configuration version on startup. Half your cluster uses v1 configuration, half uses v2. Monitor behavior. If v2 performs better, update all nodes to v2. If it causes problems, roll back to v1. Configuration versioning enables controlled changes.
Cluster-wide coordination - Store cluster-wide state that multiple nodes need to coordinate on. Leader election metadata, distributed lock information, shared counters. This isn't what the registrar is designed for (use dedicated coordination services for complex coordination), but simple coordination needs can be met with registrar configuration storage.
The embedded registrar has built-in automatic failover.
When a registrar server node terminates:
Its TCP connections to client nodes (on the same host) close
Client nodes detect the disconnection
Each client attempts to bind localhost:4499
The first to succeed becomes the new server
The rest connect to the new server as clients
Everyone re-registers their routes with the new server
This failover is automatic and takes a few milliseconds. Discovery continues without interruption.
For cross-host discovery, the same failover mechanism applies to each host independently. If a remote host's registrar server node goes down, another node on that host immediately takes over the server role. From the perspective of nodes on other hosts, discovery to that host continues working - they send UDP queries to the host, and whichever node is currently the registrar server responds. The failover is invisible to external hosts because the UDP queries are addressed to the host (port 4499), not to a specific node.
The embedded registrar is minimal by design. It provides route resolution, node listing and membership events. What it doesn't provide:
No application discovery - You can discover where nodes are, but not where specific applications are running. Want to find which nodes are running the "workers" application? You have to query every node individually or maintain that mapping yourself.
No load balancing metadata - There's no weight system for distributing load across multiple instances of the same application. You can't express that some nodes have more capacity or should receive more traffic.
No centralized configuration - Configuration lives with each node. There's no cluster-wide config store. If you want to change a setting across the cluster, you modify each node individually through node environment variables or configuration files.
Host-scoped events - Membership events cover this host only, since registration is accepted over loopback. A node appearing on another machine is not announced; it shows up on the next Nodes() call, or through the peer list of a node already known. Application lifecycle and configuration changes are not reported at all.
No topology awareness - The registrar doesn't understand your cluster structure. It treats all nodes equally. If you have nodes in different datacenters or regions, the registrar provides no metadata to help you route efficiently based on proximity or cost.
Limited scalability - The UDP query model works for small to medium clusters but doesn't scale to hundreds of nodes efficiently. Cross-host discovery has no caching - every query hits the network. For large clusters, this generates significant network traffic.
These limitations don't matter for development or small deployments. Two nodes on your laptop? Three nodes in a single datacenter? The embedded registrar works fine. But for production clusters, especially large ones or those requiring dynamic topology, you want the richer feature set of etcd or Saturn registrars.
External registrars replace the embedded implementation with centralized discovery services.
etcd registrar (ergo.services/registrar/etcd) uses etcd as the discovery backend. All nodes register their routes in etcd on startup. All discovery queries go to etcd. This centralizes cluster state: any node can discover any other node, applications can advertise their deployment locations, configuration can be stored in etcd's key-value store.
The etcd registrar does not poll. A node's registration is an etcd lease, renewed over the client's gRPC keep-alive stream, and cluster changes arrive on a prefix watch - so a peer appearing or leaving is pushed, not discovered on the next tick. The lease is what makes a dead node disappear on its own: stop renewing and the registration expires with the TTL.
What limits it at scale is etcd itself rather than a polling loop - the write load of many nodes renewing and watching the same prefix. It is a good fit up to roughly 50-70 nodes, and it brings proven reliability, extensive tooling and operational familiarity for teams already running etcd.
Saturn registrar (ergo.services/registrar/saturn) is purpose-built for Ergo clusters. It's an external Raft-based registry designed specifically for the framework's communication patterns, holding one persistent connection per node and pushing updates as cluster state changes. Both registrars push rather than poll; the difference at scale is the cost per node of holding the registration, and Saturn is built for clusters of thousands where etcd's write load becomes the ceiling.
Which registrar you choose depends on your deployment:
Small clusters (< 10 nodes), same host or trusted network: embedded registrar
Medium clusters (10-70 nodes), existing etcd infrastructure: etcd registrar
Large clusters (70+ nodes) or real-time requirements: Saturn registrar
The choice is transparent to application code. You specify the registrar in gen.NodeOptions.Network.Registrar at startup. Everything else - registration, resolution, failover - works the same way regardless of which registrar you use.
For the embedded registrar, configuration is minimal:
Setting DisableServer: true prevents the node from becoming a registrar server. It will always run in client mode. This is useful if you have a dedicated node that should handle discovery and you don't want application nodes competing for the server role.
For external registrars, configuration includes the service endpoint:
The node connects to the registrar during startup. If the connection fails, startup fails. Discovery is considered essential - if you can't register and discover, the node can't participate in the cluster, so there's no point in starting.
Service discovery is invisible during normal operation. You send messages, make calls, establish links - discovery happens automatically behind the scenes.
Where discovery becomes visible is during debugging and operations. When connections fail, understanding discovery helps diagnose why. Is the registrar unreachable? Is the target node not registered? Are the acceptor configurations incompatible?
The registrar provides an Info() method that shows its status:
This information helps you understand what discovery features are available and whether the registrar is functioning correctly.
For deeper understanding of how discovery integrates with connection establishment and message routing, see the Network Stack chapter. For configuring explicit routes that bypass discovery, see Static Routes.
func (d *DNSServer) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageUDP:
// Received UDP datagram
d.Log().Info("received %d bytes from %s", len(m.Data), m.Addr)
// Parse DNS query
query, err := d.parseDNSQuery(m.Data)
if err != nil {
d.Log().Warning("invalid DNS query from %s: %s", m.Addr, err)
return nil
}
// Build DNS response
response := d.buildDNSResponse(query)
// Send response back to the same address
d.Send(d.udpID, meta.MessageUDP{
Addr: m.Addr,
Data: response,
})
}
return nil
}type GameServer struct {
act.Actor
udpID gen.Alias
players map[string]*PlayerState // Key: remote address string
}
type PlayerState struct {
addr net.Addr
lastSeen time.Time
position Vector3
health int
}
func (g *GameServer) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageUDP:
addrStr := m.Addr.String()
// Get or create player state
player, exists := g.players[addrStr]
if !exists {
player = &PlayerState{
addr: m.Addr,
health: 100,
}
g.players[addrStr] = player
g.Log().Info("new player: %s", addrStr)
}
// Update last seen
player.lastSeen = time.Now()
// Process game packet
g.processGamePacket(player, m.Data)
case CleanupTick:
// Remove stale players
now := time.Now()
for addr, player := range g.players {
if now.Sub(player.lastSeen) > 30*time.Second {
delete(g.players, addr)
g.Log().Info("player timeout: %s", addr)
}
}
}
return nil
}options := meta.UDPServerOptions{
Port: 8125,
Process: "metrics_collector",
}// Start worker pool
poolPID, _ := process.SpawnRegister("udp_pool", createWorkerPool, gen.ProcessOptions{})
options := meta.UDPServerOptions{
Port: 8125,
Process: "udp_pool", // act.Pool is OK for UDP
}bufferPool := &sync.Pool{
New: func() any {
return make([]byte, 1500) // MTU size
},
}
options := meta.UDPServerOptions{
Port: 8125,
BufferSize: 1500,
BufferPool: bufferPool,
}func (s *StatsServer) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageUDP:
// Process datagram
s.processMetric(m.Data)
// Return buffer to pool
bufferPool.Put(m.Data)
}
return nil
}case meta.MessageUDP:
// Store in queue - must copy
copied := make([]byte, len(m.Data))
copy(copied, m.Data)
s.queue = append(s.queue, copied)
// Return original buffer
bufferPool.Put(m.Data)// DNS server - queries rarely exceed 512 bytes
options := meta.UDPServerOptions{
Port: 53,
BufferSize: 512,
}
// Game server - small position updates
options := meta.UDPServerOptions{
Port: 9999,
BufferSize: 128,
}
// Media streaming - large packets OK
options := meta.UDPServerOptions{
Port: 5004,
BufferSize: 8192,
}type ReassemblyHandler struct {
act.Actor
fragments map[uint32]*FragmentSet // Key: message ID
}
type FragmentSet struct {
fragments []*Fragment
received map[int]bool
total int
}
func (r *ReassemblyHandler) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageUDP:
// Parse fragment header
msgID, fragNum, totalFrags := r.parseFragmentHeader(m.Data)
// Get or create fragment set
set, exists := r.fragments[msgID]
if !exists {
set = &FragmentSet{
fragments: make([]*Fragment, totalFrags),
received: make(map[int]bool),
total: totalFrags,
}
r.fragments[msgID] = set
}
// Store fragment
set.fragments[fragNum] = &Fragment{data: m.Data}
set.received[fragNum] = true
// Check if complete
if len(set.received) == set.total {
complete := r.reassemble(set.fragments)
r.processMessage(complete)
delete(r.fragments, msgID)
}
bufferPool.Put(m.Data)
}
return nil
}type Player struct {
lastSequence uint32
}
func (g *GameServer) processGamePacket(player *Player, data []byte) {
seq := binary.BigEndian.Uint32(data[0:4])
// Discard old/duplicate packets
if seq <= player.lastSequence {
return
}
player.lastSequence = seq
// Process packet
}type Measurement struct {
timestamp time.Time
value float64
}
func (s *StatsCollector) processMeasurement(m Measurement) {
// Store measurements in order by timestamp
s.insertSorted(m)
}serverInfo, _ := process.InspectMeta(udpID)
// Returns: map[string]string{
// "listener": "0.0.0.0:8125",
// "process": "metrics_collector",
// "bytes in": "10485760",
// "bytes out": "1048576",
// }type MetricsCollector struct {
act.Actor
metrics map[string]*Metric
}
func (m *MetricsCollector) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case meta.MessageUDP:
// Parse StatsD format: "metric.name:value|type"
name, value, metricType := m.parseStatsD(msg.Data)
metric := m.metrics[name]
if metric == nil {
metric = &Metric{}
m.metrics[name] = metric
}
metric.update(value, metricType)
bufferPool.Put(msg.Data)
case FlushTick:
// Periodically flush aggregated metrics
m.flushMetrics()
m.metrics = make(map[string]*Metric)
}
return nil
}type DNSClient struct {
act.Actor
udpID gen.Alias
pending map[uint16]*PendingQuery // Key: DNS query ID
}
func (c *DNSClient) query(domain string) {
queryID := c.nextQueryID()
query := c.buildDNSQuery(queryID, domain)
// Send query
c.Send(c.udpID, meta.MessageUDP{
Addr: c.dnsServerAddr,
Data: query,
})
// Arm the timeout. SendAfter returns (gen.CancelFunc, error), so it cannot
// sit inside a struct literal.
cancel, err := c.SendAfter(c.PID(), QueryTimeout{queryID}, 5*time.Second)
if err != nil {
c.Log().Error("cannot arm the query timeout: %s", err)
return
}
c.pending[queryID] = &PendingQuery{
domain: domain,
sent: time.Now(),
timeout: cancel,
}
}
func (c *DNSClient) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageUDP:
// Parse DNS response
queryID := binary.BigEndian.Uint16(m.Data[0:2])
pending := c.pending[queryID]
if pending != nil {
pending.timeout() // Cancel timeout
c.handleResponse(m.Data)
delete(c.pending, queryID)
}
bufferPool.Put(m.Data)
case QueryTimeout:
// Query timed out - maybe retry
pending := c.pending[m.queryID]
if pending != nil {
c.Log().Warning("DNS query timeout: %s", pending.domain)
delete(c.pending, m.queryID)
}
}
return nil
}type DiscoveryServer struct {
act.Actor
udpID gen.Alias
}
func (d *DiscoveryServer) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageUDP:
if string(m.Data) == "DISCOVER" {
response := d.buildDiscoveryResponse()
// Reply to sender
d.Send(d.udpID, meta.MessageUDP{
Addr: m.Addr,
Data: response,
})
}
bufferPool.Put(m.Data)
}
return nil
}// WRONG: Buffer leaked
func (s *Server) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageUDP:
// Store in queue without copying
s.queue = append(s.queue, m.Data) // Buffer still referenced!
}
return nil
}
// CORRECT: Copy before storing
func (s *Server) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageUDP:
copied := make([]byte, len(m.Data))
copy(copied, m.Data)
s.queue = append(s.queue, copied)
bufferPool.Put(m.Data) // Return original
}
return nil
}// WRONG: Assumes all datagrams arrive
func (c *Client) sendTransaction(tx Transaction) {
// Send 10 chunks
for i := 0; i < 10; i++ {
chunk := tx.getChunk(i)
c.Send(c.udpID, meta.MessageUDP{
Addr: c.serverAddr,
Data: chunk,
})
}
// Server will process when all 10 arrive... right? WRONG!
}// WRONG: Likely to be fragmented or lost
data := make([]byte, 8000) // 8KB datagram
c.Send(c.udpID, meta.MessageUDP{
Addr: serverAddr,
Data: data,
})// WRONG: Processes duplicate commands
func (g *GameServer) handleCommand(player *Player, cmd Command) {
switch cmd.Type {
case CmdFireWeapon:
player.ammo-- // Duplicate datagram = fire twice!
g.spawnProjectile(player)
}
}
// CORRECT: Idempotent with sequence tracking
func (g *GameServer) handleCommand(player *Player, cmd Command) {
if cmd.Sequence <= player.lastSequence {
return // Duplicate or old command
}
player.lastSequence = cmd.Sequence
switch cmd.Type {
case CmdFireWeapon:
player.ammo--
g.spawnProjectile(player)
}
}registrar, err := node.Network().Registrar()
if err != nil {
return err
}
nodes, err := registrar.Nodes() // other nodes, without this one
event, err := registrar.Event() // membership changes
process.MonitorEvent(event)route := gen.ApplicationRoute{
Node: node.Name(),
Name: "workers",
Weight: 100,
Mode: gen.ApplicationModePermanent,
State: gen.ApplicationStateRunning,
}
registrar.RegisterApplicationRoute(route)registrar, _ := node.Network().Registrar()
resolver := registrar.Resolver()
routes, err := resolver.ResolveApplication("workers")
// routes contains all nodes running "workers" applicationroutes, err := node.Network().ResolveApplication("workers")routes, _ := node.Network().ResolveApplication("workers")
for _, route := range routes {
node.Log().Info("%s runs %s %s", route.Node, route.Name, route.Version.Release)
}routes, _ := node.Network().ResolveApplication("workers")
ready := routes.
WithTags("ready").
WithoutTags("draining").
WithState(gen.ApplicationStateRunning)routes, _ := node.Network().ResolveApplication("workers")
// routes = gen.ApplicationRoutes{
// {Name: "workers", Node: "worker1@host1", Weight: 100, Mode: Permanent, State: Running},
// {Name: "workers", Node: "worker2@host2", Weight: 50, Mode: Permanent, State: Running},
// {Name: "workers", Node: "worker3@host3", Weight: 200, Mode: Permanent, State: Running},
// }app := process.Application()
app.SetWeight(-1) // stop receiving traffic; the instance keeps running
app.SetWeight(100) // back in rotationregistrar, _ := node.Network().Registrar()
// Get single config item
dbURL, err := registrar.ConfigItem("database_url")
// Get multiple items
config, err := registrar.Config("database_url", "cache_size", "log_level")
// config = map[string]any{
// "database_url": "postgres://...",
// "cache_size": 1024,
// "log_level": "info",
// }// For etcd registrar
import "ergo.services/registrar/etcd"
// For Saturn registrar
import "ergo.services/registrar/saturn"
registrar, _ := node.Network().Registrar()
event, err := registrar.Event()
if err != nil {
// this registrar does not support events
}
// Link to the event to receive notifications
process.LinkEvent(event)
// In your HandleEvent callback (etcd example):
func (w *Worker) HandleEvent(event gen.MessageEvent) error {
switch ev := event.Message.(type) {
case etcd.EventConfigUpdate:
// Configuration item changed
w.Log().Info("config updated: %s = %v", ev.Item, ev.Value)
w.loadConfig()
case etcd.EventNodeJoined:
// New node joined the cluster
w.Log().Info("node joined: %s", ev.Name)
w.checkNewNode(ev.Name)
case etcd.EventNodeLeft:
// Node left the cluster
w.Log().Info("node left: %s", ev.Name)
w.handleNodeDown(ev.Name)
case etcd.EventApplicationLoaded:
// Application loaded on a node
w.Log().Info("application %s loaded on %s (weight: %d)",
ev.Name, ev.Node, ev.Weight)
case etcd.EventApplicationStarted:
// Application started running
w.Log().Info("application %s started on %s (mode: %s, weight: %d)",
ev.Name, ev.Node, ev.Mode, ev.Weight)
w.refreshServices()
case etcd.EventApplicationStopping:
// Application is stopping
w.Log().Info("application %s stopping on %s", ev.Name, ev.Node)
case etcd.EventApplicationStopped:
// Application stopped completely
w.Log().Info("application %s stopped on %s", ev.Name, ev.Node)
w.refreshServices()
case etcd.EventApplicationUnloaded:
// Application unloaded from node
w.Log().Info("application %s unloaded from %s", ev.Name, ev.Node)
}
return nil
}
// For Saturn registrar, use saturn.EventConfigUpdate, saturn.EventNodeJoined, etc.
// The event types are identical in structure but defined in separate packages.import "ergo.services/ergo/net/registrar"
node, err := ergo.StartNode("myapp@localhost", gen.NodeOptions{
Network: gen.NetworkOptions{
Registrar: registrar.Create(registrar.Options{
Port: 4499, // default
DisableServer: false, // allow server mode
}),
},
})import "ergo.services/registrar/etcd"
// etcd.Create returns (gen.Registrar, error) - unlike the embedded
// registrar.Create above, which returns a single value
registrar, err := etcd.Create(etcd.Options{
Endpoints: []string{"etcd1:2379", "etcd2:2379", "etcd3:2379"},
// ... authentication, TLS, etc
})
if err != nil {
panic(err)
}
node, err := ergo.StartNode("myapp@prod.example.com", gen.NodeOptions{
Network: gen.NetworkOptions{
Registrar: registrar,
},
})network := node.Network()
registrar, err := network.Registrar()
if err != nil {
// node has no registrar configured
}
info := registrar.Info()
// info.Server - registrar endpoint
// info.EmbeddedServer - true if running as server
// info.SupportConfig - whether config storage is available
// info.SupportRegisterApplication - whether app routing is availableHTTP and actors speak different languages. HTTP is fundamentally synchronous - a request arrives, blocks waiting for processing, gets a response, connection closes. The actor model is fundamentally asynchronous - messages arrive in mailboxes, get processed sequentially one at a time, responses are separate messages sent whenever ready.
Integrating these two worlds is possible, but the integration strategy matters. Choose wrong and you lose the benefits of both models. Choose right and you get HTTP's ubiquity with actors' concurrency and distribution capabilities.
This chapter shows two integration approaches, ordered from simple to complex. The simple approach works for most cases and keeps the entire HTTP ecosystem available. The meta-process approach trades tooling for deeper actor integration, enabling patterns impossible with standard HTTP stacks.
Before reaching for meta-processes, understand what you're giving up and what you're gaining. The simple approach might be all you need.
The straightforward way: run a standard HTTP server, call actors from handlers using node.Call(), let network transparency distribute requests across the cluster.
This keeps HTTP and actors separate. HTTP handles protocol concerns - routing, middleware, headers, status codes. Actors handle business logic - state management, processing, coordination. Clean separation.
func main() {
// Start node
node, err := ergo.StartNode("gateway@localhost", gen.NodeOptions{})
if err != nil {
panic(err)
}
defer node.Stop()
// Start HTTP server with node reference
server := &APIServer{node: node}
if err := server.Start(); err != nil {
panic(err)
}
}
type APIServer struct {
node gen.Node
mux *http.ServeMux
}
func (a *APIServer) Start() error {
a.mux = http.NewServeMux()
a.mux.HandleFunc("/users/{id}", a.handleGetUser)
a.mux.HandleFunc("/orders", a.handleCreateOrder)
return http.ListenAndServe(":8080", a.mux)
}
func (a *APIServer) handleGetUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
// Call actor anywhere in the cluster
result, err := a.node.Call(
gen.ProcessID{Name: "user-service", Node: "backend@node1"},
GetUserRequest{ID: userID},
)
if err != nil {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
if errResult, ok := result.(error); ok {
http.Error(w, errResult.Error(), http.StatusNotFound)
return
}
user := result.(User)
json.NewEncoder(w).Encode(user)
}The HTTP server runs outside the actor system in a separate goroutine. Handlers call actors synchronously using node.Call(). Actors can be anywhere - same node, remote node, doesn't matter. Network transparency routes the call.
Call() blocks the HTTP handler goroutine, not an actor. Go's HTTP server creates one goroutine per connection. Blocking in a handler is normal - that goroutine waits, others continue serving requests.
The actor receiving the call processes it asynchronously in its own message loop. Multiple handlers can call the same actor concurrently. The actor processes one request at a time from its mailbox. This isolates the actor from HTTP concurrency.
Network transparency means the actor can be anywhere:
Change Node to move the actor. Code stays the same. Distribute load across nodes by routing different requests to different actors.
Network transparency means actors can run anywhere in the cluster. The HTTP gateway becomes a router that distributes requests across backend nodes.
Simple consistent hashing distributes load evenly while maintaining request affinity:
Requests for user "alice" always go to the same backend node. That node caches alice's data in memory. Subsequent requests hit warm cache. Change clusterSize to add nodes - hashing redistributes load automatically while preserving most affinity.
For dynamic topology where nodes join and leave unpredictably, use application discovery. Central registrars (etcd, Saturn) track which nodes are running which applications in real-time:
Application discovery returns all nodes currently running the service. Each node reports its weight. Nodes with higher weights (more resources, better hardware, closer proximity) receive proportionally more traffic. Nodes that crash disappear from discovery immediately. New nodes appear as soon as they register. The HTTP gateway adapts to cluster topology changes without restarts.
For details on application discovery and central registrars, see .
This approach keeps the entire HTTP ecosystem available:
OpenAPI generation: Tools like swag/swaggo analyze HTTP handlers and generate OpenAPI specs. They see standard net/http handlers, so generation works normally.
Middleware: Standard HTTP middleware wraps handlers - authentication, logging, CORS, rate limiting. Actors are completely invisible to middleware.
Routing: Use any router - http.ServeMux (Go 1.22+), gorilla/mux, chi, echo. They all work with standard handlers.
Testing: Test HTTP handlers with httptest. Test actors separately with unit tests. Clean separation of concerns.
The actor system is an implementation detail. HTTP sees standard handlers. Clients see standard HTTP. Deployment tools see standard HTTP servers. Only the handler implementation uses actors internally.
Use this when:
You need standard HTTP tooling (OpenAPI, gRPC-gateway, middleware ecosystems)
Load balancing happens at the nginx/kubernetes level, not actor level
Backpressure from actors doesn't matter (actors process at their speed, HTTP clients wait)
You want simple deployment (separate HTTP gateway, actor backend)
This covers most HTTP/actor integration cases. The HTTP layer is stateless. Actors hold state and logic. HTTP routes requests to actors. Clean architecture.
For details on synchronous request handling in actors, see .
Meta-processes convert HTTP into asynchronous actor messages. Instead of calling actors synchronously from handlers, requests become messages flowing into the actor system.
This approach enables:
Backpressure: actors control request rate through mailbox capacity
Addressable connections: each WebSocket/SSE connection becomes an independent actor with gen.Alias identifier - any actor anywhere in the cluster can send messages directly to specific client connections through network transparency. This is the killer feature for real-time systems (chat, multiplayer games, live dashboards, collaborative editing) where backend logic must push updates to specific clients across cluster nodes. Impossible with the simple approach.
Per-request routing: route to different actor pools based on request content
Standard HTTP routing and middleware still work - meta.WebHandler implements http.Handler and integrates with http.ServeMux or any router. You can wrap handlers in middleware for authentication, logging, CORS. What you lose is introspection-based tooling (OpenAPI generation, gRPC-gateway) because request processing happens inside actors, invisible to HTTP layer analysis tools.
Two meta-processes work together:
meta.WebServer: External Reader runs http.Server.Serve(listener). Blocks there forever until listener fails. The http.Server creates its own goroutines for each HTTP connection - those goroutines call handlers, not the External Reader. Actor Handler never runs (no messages received).
meta.WebHandler: Implements http.Handler interface. External Reader blocks in Start() waiting for termination. When http.Server (running in WebServer) accepts a connection, it spawns a goroutine that calls handler.ServeHTTP(). Inside ServeHTTP():
Create context with timeout
Send meta.MessageWebRequest to worker actor
Block on <-ctx.Done() waiting for worker to call Done()
Actor Handler never runs - HandleMessage() and HandleCall() are empty stubs.
Worker actors receive meta.MessageWebRequest containing:
http.ResponseWriter - write response here
*http.Request - the HTTP request
Done() function - call this to unblock ServeHTTP
Compare this with typical meta-processes like TCP or UDP:
TCP/UDP meta-processes:
External Reader actively loops reading from socket, sends messages to actors
Actor Handler receives messages from actors, writes to socket
Both goroutines do real work - continuous bidirectional I/O
Web meta-processes:
WebServer's External Reader passively blocks in http.Server.Serve() doing nothing - http.Server does all the work internally
WebHandler's External Reader passively blocks on channel doing nothing - just waiting for termination
Neither has an active Actor Handler - no messages arrive in their mailboxes
Web meta-processes are unusual. They use the meta-process mechanism not for bidirectional I/O but for lifecycle management and integration with the actor system. The External Reader goroutines exist only to keep the meta-process alive while http.Server runs. The actual HTTP handling happens in goroutines spawned by http.Server, which are completely outside the meta-process architecture.
This works because http.Server already solves concurrency - it spawns goroutines per connection. The meta-process just wraps it for integration with actor lifecycle and messaging.
When a request arrives:
WebServer's External Reader is blocked in http.Server.Serve()
http.Server accepts connection, spawns its own goroutine for this connection
That goroutine calls handler.ServeHTTP() (handler is WebHandler)
Critical: ServeHTTP() executes in http.Server goroutines, not in meta-process goroutines. WebHandler's External Reader remains blocked in Start() waiting for termination. WebHandler's Actor Handler never spawns because no messages arrive in its mailbox.
Four of those steps can fail, and each has its own status. The handler answers them itself - your worker is never involved:
RequestTimeout defaults to 5 seconds when left at zero.
How the body is written is your choice. WebHandlerOptions.Refusal takes over the whole response, and leaving it nil answers with http.Error - plain text, which a client expecting JSON or gRPC cannot read. If your endpoint has an error contract of its own, set Refusal.
Workers receive meta.MessageWebRequest as regular messages in their mailbox:
The pattern: receive MessageWebRequest, process it, write to ResponseWriter, call Done(). The Done() call unblocks the ServeHTTP() goroutine waiting in WebHandler.
Using act.WebWorker: Framework provides act.WebWorker that automatically extracts MessageWebRequest, routes to HTTP-method-specific callbacks (HandleGet, HandlePost, etc.), and calls Done() after processing. Use this instead of manual message handling - it eliminates boilerplate and ensures Done() is always called. See for details.
Single worker processes requests sequentially. Use act.Pool to process multiple requests concurrently:
Pool distributes incoming requests across 20 workers. Each worker processes one request at a time. System handles 20 concurrent requests.
Capacity control: the backend holds PoolSize requests in flight plus PoolSize × WorkerMailboxSize queued behind them - with 20 workers and a mailbox of 10, that is 20 in progress and 200 queued, 220 in total. A message being handled has already been taken out of its mailbox, which is why the two terms add rather than one containing the other. It is not an admission limit, and past it requests are not shed quickly.
What happens instead: the pool cannot place the message, so it drops and logs it. Nothing cancels the HTTP request waiting on that message, so the handler waits out RequestTimeout and answers 504 Gateway Timeout. Every excess request therefore holds a connection for the whole timeout - five seconds by default. Under sustained overload that is worse than a fast rejection: keep RequestTimeout short on a public endpoint, watch the pool's ergo:messages_unhandled, and reject at the edge if you want a real 503.
This limits load on backend systems. Database handles 20 concurrent queries maximum. External API gets 20 parallel requests maximum. Worker mailboxes buffer bursts without overwhelming downstream services.
Worker failures are handled automatically. Pool spawns replacement workers when crashes are detected. Other workers continue processing during restart.
HTTP request-response is stateless. WebSocket is the opposite - long-lived bidirectional connections remaining open for hours or days.
The framework provides WebSocket meta-process implementation in the extra library (ergo.services/meta/websocket). Each connection becomes an independent meta-process with gen.Alias identifier, addressable from anywhere in the cluster.
Each connection is an independent meta-process:
External Reader continuously reads messages from client
Actor Handler receives messages from backend actors, writes to client
Both operate simultaneously - full-duplex bidirectional communication
Connection has state (subscriptions, session data) managed by the actor
Killer feature: cluster-wide addressability. Any actor on any node can send messages directly to specific client connections:
Network transparency makes every WebSocket connection addressable like any other actor. Backend logic scattered across cluster nodes can push updates to specific clients without routing through intermediaries.
This is impossible with the simple approach. node.Call() is request-response. WebSocket requires continuous streaming both directions. Meta-processes provide the architecture: one goroutine reading from client, another writing to client, both operating on the same connection.
For WebSocket implementation and usage examples, see .
Start with the simple approach. Use node.Call() from standard HTTP handlers. This works for most cases and keeps the entire HTTP ecosystem available - OpenAPI generation, middleware, familiar patterns.
Move to meta-processes when you specifically need:
WebSocket or long-lived connections: Each connection must be an addressable actor that backend logic can push updates to. The simple approach cannot do this - it's request-response only. Meta-processes make each connection an independent actor with cluster-wide addressability.
Capacity control through mailbox limits: the backend holds a bounded amount of work - PoolSize requests in flight plus PoolSize × WorkerMailboxSize queued behind them - instead of letting the HTTP server queue without limit. Note what "bounded" buys you: past that point the excess is dropped and each of those requests occupies a connection until RequestTimeout expires, as described above. It bounds memory, not latency.
The simple approach handles thousands of requests per second with proper actor distribution. Use meta-processes only when the simple approach cannot provide required capabilities.
Network services need to accept TCP connections, read data from sockets, and write responses - all blocking operations that don't fit the one-message-at-a-time actor model. You could spawn goroutines for each connection, but this breaks actor isolation. You need synchronization, careful lifecycle management, and lose the benefits of supervision trees.
TCP meta-processes solve this by wrapping socket I/O in actors. The framework handles accept loops, connection management, and data buffering. Your actors receive messages when connections arrive or data is read. To send data, you send a message to the connection's meta-process. The actor model stays intact while integrating with blocking TCP operations.
Ergo provides two TCP meta-processes: TCPServer for accepting connections, and TCPConnection for handling established connections (both incoming and outgoing).
Create a TCP server with meta.CreateTCPServer:
type EchoServer struct {
act.Actor
}
func (e *EchoServer) Init(args ...any) error {
options := meta.TCPServerOptions{
Host: "0.0.0.0", // Listen on all interfaces
Port: 8080,
}
server, err := meta.CreateTCPServer(options)
if err != nil {
return fmt.Errorf("failed to create TCP server: %w", err)
}
// Start the server meta-process
serverID, err := e.SpawnMeta(server, gen.MetaOptions{})
if err != nil {
// Failed to spawn - close the listening socket
server.Terminate(err)
return fmt.Errorf("failed to spawn TCP server: %w", err)
}
e.Log().Info("TCP server listening on %s:%d (id: %s)",
options.Host, options.Port, serverID)
return nil
}The server opens a TCP socket and enters an accept loop. When a connection arrives, the server spawns a new TCPConnection meta-process to handle it. Each connection runs in its own meta-process, isolated from other connections.
If SpawnMeta fails, you must call server.Terminate(err) to close the listening socket. Without this, the port remains bound and unusable until the process exits.
The server runs forever, accepting connections and spawning handlers. When the parent actor terminates, the server terminates too (cascading termination), closing the listening socket and stopping all connection handlers.
When the server accepts a connection, it automatically spawns a TCPConnection meta-process. This meta-process reads data from the socket and sends it to your actor. To write data, you send messages to the connection's meta-process.
MessageTCPConnect arrives when the connection is established. It contains the connection's meta-process ID (m.ID), remote address, and local address. Save the ID if you need to track connections or send data later.
MessageTCP arrives when data is read from the socket. m.Data contains the bytes read (up to ReadBufferSize at a time). To send data, send a MessageTCP back to the connection's ID. The meta-process writes it to the socket.
MessageTCPDisconnect arrives when the connection closes (client disconnected, network error, or you terminated the connection). After this, the connection meta-process is dead - sending to its ID returns an error.
If the connection meta-process cannot send messages to your actor (actor crashed, mailbox full), it terminates the connection and stops. This ensures failed actors don't leak connections.
By default, all connections send messages to the parent actor - the one that spawned the server. For a server handling many connections, this creates a bottleneck. All connections compete for the parent's mailbox, and messages are processed sequentially.
Use ProcessPool to distribute connections across multiple workers:
The server distributes connections round-robin across the pool. Connection 1 goes to tcp_worker_0, connection 2 goes to tcp_worker_1, and so on. After tcp_worker_9, it wraps back to tcp_worker_0.
Each worker handles its connections independently. If a worker crashes, its connections terminate (they can't send messages anymore). The supervisor restarts the worker, which begins handling new connections. The distribution is stateless - the server doesn't track which worker handles which connection.
Do not use act.Pool in ProcessPool. act.Pool forwards messages to any available worker, breaking the connection-to-worker binding. If connection A sends message 1 to worker X and message 2 to worker Y, the protocol state becomes corrupted. Use a list of individual process names instead.
Workers are typically actors that maintain per-connection state:
To initiate outgoing TCP connections, use meta.CreateTCPConnection:
CreateTCPConnection connects to the remote host immediately. If the connection fails (host unreachable, connection refused), it returns an error. If successful, it returns a meta-process behavior ready to spawn.
The spawned meta-process sends MessageTCPConnect when ready, then streams received data as MessageTCP messages. To send data, send MessageTCP to the connection's ID.
Client connections use the same TCPConnection meta-process as server-side connections. The only difference is how they're created: CreateTCPConnection initiates a connection, while the server spawns connections automatically on accept.
Raw TCP is a byte stream, not a message stream. If you send two 100-byte messages, they might arrive as one 200-byte read, or three reads (150 bytes, 40 bytes, 10 bytes). You must frame messages to detect boundaries.
Enable chunking for automatic framing:
Fixed-length messages:
Every MessageTCP contains exactly 256 bytes. The meta-process buffers reads until 256 bytes accumulate, then sends them. If a socket read returns 512 bytes, you receive two MessageTCP messages.
Header-based messages:
The meta-process reads the 4-byte header, extracts the length as a big-endian integer, waits for the full payload, then sends the complete message (header + payload) as one MessageTCP.
Protocol example:
You receive:
First MessageTCP: 14 bytes (4 + 10)
Second MessageTCP: 260 bytes (4 + 256)
If both messages arrive in one socket read (274 bytes total), the meta-process splits them automatically. If the header arrives first and the payload arrives later (slow connection), the meta-process waits for the complete message.
MaxLength protects against malformed or malicious messages. If the header claims a message is 4GB, the meta-process terminates with gen.ErrTooLarge instead of allocating 4GB of memory.
HeaderLengthSize can be 1, 2, or 4 bytes (big-endian). HeaderLengthPosition specifies the offset within the header. Example for a protocol with type + flags + length:
Without chunking, you receive raw bytes as the meta-process reads them. You must buffer and frame messages yourself - typically by accumulating data in your actor's state and detecting message boundaries manually.
The meta-process allocates buffers for reading socket data. By default, each read allocates a new buffer, which becomes garbage after you process it. For high-throughput servers, this causes GC pressure.
Use a buffer pool:
The meta-process gets buffers from the pool when reading. When you receive MessageTCP, the Data field is a buffer from the pool. Return it to the pool after processing:
When you send MessageTCP to write data, the meta-process automatically returns the buffer to the pool after writing (if a pool is configured). Don't use the buffer after sending.
If you need to store data beyond the current message, copy it:
Buffer pools are essential for servers handling thousands of connections or high throughput. For low-volume clients, the GC overhead is negligible - skip the pool for simplicity.
Some protocols require periodic writes to keep connections alive. If no data is sent for a timeout period, the peer disconnects. You could send keepalive messages with timers, but this is tedious and error-prone.
Enable automatic keepalive:
The meta-process wraps the socket with a keepalive writer. If nothing is written for 30 seconds, it automatically sends a null byte. The peer receives it as normal data. Design your protocol to ignore keepalive messages.
Keepalive bytes can be anything: a ping message, a heartbeat packet, or a protocol-specific keepalive. The peer sees them as regular socket data.
This is application-level keepalive (layer 7), not TCP keepalive (layer 4). Both can be used simultaneously.
TCP has built-in keepalive at the protocol level. Enable it with KeepAlivePeriod:
The OS sends TCP keepalive probes every 60 seconds when the connection is idle. If the peer doesn't respond, the connection is closed. This detects dead connections (network partition, crashed peer) without application involvement.
The value is handed straight to net.ListenConfig.KeepAlive (and to net.Dialer.KeepAlive for a client connection), so it carries Go's meaning rather than an inverted one. Leaving KeepAlivePeriod unset is the zero value, and zero means keepalive is enabled with the operating system's own period - typically two hours on Linux, varying by platform. A positive duration sets the probe period explicitly. To turn keepalive off, set it negative.
TCP keepalive (OS-level) and write buffer keepalive (application-level) serve different purposes:
TCP keepalive: Detects dead connections
Write keepalive: Satisfies application protocols that require periodic data
Most servers need TCP keepalive to clean up dead connections. Some protocols also need write keepalive to satisfy their requirements.
Enable TLS with a certificate manager:
The server wraps accepted connections with TLS. The certificate manager provides certificates dynamically (for SNI, certificate rotation, etc.). See for details.
For client connections:
The client establishes a TLS connection during CreateTCPConnection. By default, the client verifies the server's certificate. To skip verification (testing only):
Never use InsecureSkipVerify in production. It disables certificate validation, making you vulnerable to man-in-the-middle attacks.
With TLS enabled, data is encrypted automatically. Your actor sends and receives plaintext MessageTCP - the meta-process handles encryption/decryption transparently.
For both server and client connections, you can route messages to a specific process:
If Process is not set (client) or ProcessPool is empty (server), messages go to the parent actor.
For servers, ProcessPool enables load distribution. For clients, Process enables separation of concerns - the actor that initiates connections doesn't need to handle the protocol.
TCP meta-processes support inspection for debugging:
A meta process is inspected by its alias through InspectMeta, not by a Call. The request travels the meta's system queue and is answered by its HandleInspect, which a Call never reaches. gen.Node carries the same method for callers that are not processes.
Use this for monitoring, debugging, or displaying connection status in management interfaces.
Pattern: Connection registry
Track all active connections. Useful for monitoring, rate limiting, or forced disconnection.
Pattern: Protocol state machine
Maintain per-connection protocol state for complex protocols with multiple stages (handshake, authentication, data transfer).
Pattern: Broadcast to all connections
Send the same data to all active connections. Useful for chat servers, pub/sub systems, or monitoring dashboards.
Pitfall: Not handling MessageTCPDisconnect
After disconnect, the connection state remains in memory forever. Always clean up on disconnect.
Pitfall: act.Pool in ProcessPool
If worker_pool is an act.Pool, messages from one connection are distributed across multiple workers. Connection A's messages might go to worker 1, then worker 2, then worker 1 again. Protocol state is split across workers, causing corruption.
Use individual process names, not pools.
Pitfall: Blocking in message handler
If the worker handles multiple connections, one slow operation blocks all of them. The worker can't process messages from other connections while blocked.
Solution: Spawn a goroutine for slow operations, or use a worker pool (one worker per connection).
Pitfall: Forgetting to return buffers
Pool buffers are reused. Storing them directly leads to data corruption. Always copy, then return.
TCP meta-processes handle the complexity of socket I/O, connection management, and buffering - letting you focus on protocol implementation while maintaining the actor model's isolation and supervision benefits.
Actors communicate through message passing within the framework. But what if you need to integrate with an external program written in Python, C, or any other language? You could spawn goroutines to manage stdin/stdout, handle protocol framing, deal with buffer management - but this breaks the actor model and spreads I/O complexity throughout your code.
Port meta-process solves this by wrapping external programs as actors. The external program runs as a child process. You send messages to the Port, and it writes them to the program's stdin. The Port reads from stdout and sends you messages. From your actor's perspective, you're just exchanging messages with another actor - the external program's details are abstracted away.
This enables clean integration with legacy systems, specialized libraries in other languages, or any tool that uses stdin/stdout for communication. The actor model stays intact while bridging to external processes.
Create a Port with meta.CreatePort and spawn it as a meta-process:
The Port starts the external program and establishes three pipes: stdin (for writing), stdout (for reading), and stderr (for errors). The program runs as a child process managed by the Port meta-process.
When the Port starts, it sends MessagePortStart
func (e *EchoServer) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCPConnect:
// New connection established
e.Log().Info("client connected: %s -> %s (id: %s)",
m.RemoteAddr, m.LocalAddr, m.ID)
// Send welcome message
e.Send(m.ID, meta.MessageTCP{
Data: []byte("Welcome to echo server!\n"),
})
case meta.MessageTCP:
// Received data from client
e.Log().Info("received %d bytes from %s", len(m.Data), m.ID)
// Echo it back
e.Send(m.ID, meta.MessageTCP{
Data: m.Data,
})
case meta.MessageTCPDisconnect:
// Connection closed
e.Log().Info("client disconnected: %s", m.ID)
}
return nil
}type TCPDispatcher struct {
act.Actor
}
func (d *TCPDispatcher) Init(args ...any) error {
// Start worker pool
for i := 0; i < 10; i++ {
workerName := gen.Atom(fmt.Sprintf("tcp_worker_%d", i))
_, err := d.SpawnRegister(workerName, createWorker, gen.ProcessOptions{})
if err != nil {
return err
}
}
// Configure server with worker pool
options := meta.TCPServerOptions{
Port: 8080,
ProcessPool: []gen.Atom{
"tcp_worker_0",
"tcp_worker_1",
"tcp_worker_2",
"tcp_worker_3",
"tcp_worker_4",
"tcp_worker_5",
"tcp_worker_6",
"tcp_worker_7",
"tcp_worker_8",
"tcp_worker_9",
},
}
server, err := meta.CreateTCPServer(options)
if err != nil {
return err
}
_, err = d.SpawnMeta(server, gen.MetaOptions{})
if err != nil {
server.Terminate(err)
return err
}
return nil
}type TCPWorker struct {
act.Actor
connections map[gen.Alias]*ConnectionState
}
type ConnectionState struct {
remoteAddr net.Addr
buffer []byte
// ... protocol state
}
func (w *TCPWorker) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCPConnect:
w.connections[m.ID] = &ConnectionState{
remoteAddr: m.RemoteAddr,
}
case meta.MessageTCP:
state := w.connections[m.ID]
w.processData(m.ID, state, m.Data)
case meta.MessageTCPDisconnect:
delete(w.connections, m.ID)
}
return nil
}type HTTPClient struct {
act.Actor
connID gen.Alias
}
func (c *HTTPClient) Init(args ...any) error {
options := meta.TCPConnectionOptions{
Host: "example.com",
Port: 80,
}
connection, err := meta.CreateTCPConnection(options)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
connID, err := c.SpawnMeta(connection, gen.MetaOptions{})
if err != nil {
connection.Terminate(err)
return fmt.Errorf("failed to spawn connection: %w", err)
}
c.connID = connID
return nil
}
func (c *HTTPClient) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCPConnect:
// Connection established, send HTTP request
request := "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
c.Send(m.ID, meta.MessageTCP{
Data: []byte(request),
})
case meta.MessageTCP:
// Received HTTP response
c.Log().Info("response: %s", string(m.Data))
case meta.MessageTCPDisconnect:
// Server closed connection
c.Log().Info("connection closed by server")
}
return nil
}options := meta.TCPServerOptions{
Port: 8080,
ReadChunk: meta.ChunkOptions{
Enable: true,
FixedLength: 256, // Every message is exactly 256 bytes
},
}options := meta.TCPServerOptions{
Port: 8080,
ReadBufferSize: 8192,
ReadChunk: meta.ChunkOptions{
Enable: true,
// Protocol: [4-byte length][payload]
HeaderSize: 4,
HeaderLengthPosition: 0,
HeaderLengthSize: 4,
HeaderLengthIncludesHeader: false, // Length is payload only
MaxLength: 1048576, // Max 1MB per message
},
}Message 1: [0x00 0x00 0x00 0x0A] [10 bytes payload]
Message 2: [0x00 0x00 0x01 0x00] [256 bytes payload]// Protocol: [type][flags][length-MSB][length-LSB][payload]
ReadChunk: meta.ChunkOptions{
Enable: true,
HeaderSize: 4,
HeaderLengthPosition: 2, // Length starts at byte 2
HeaderLengthSize: 2, // 2-byte length
}bufferPool := &sync.Pool{
New: func() any {
return make([]byte, 8192)
},
}
options := meta.TCPServerOptions{
Port: 8080,
ReadBufferSize: 8192,
ReadBufferPool: bufferPool,
}func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCP:
// Process data
result := w.processPacket(m.Data)
// Send response
w.Send(m.ID, meta.MessageTCP{Data: result})
// Return read buffer to pool
bufferPool.Put(m.Data)
}
return nil
}case meta.MessageTCP:
state := w.connections[m.ID]
// Store in connection state - must copy
state.buffer = append(state.buffer, m.Data...)
// Return original buffer
bufferPool.Put(m.Data)options := meta.TCPServerOptions{
Port: 8080,
WriteBufferKeepAlive: []byte{0x00}, // Send null byte
WriteBufferKeepAlivePeriod: 30 * time.Second,
}options := meta.TCPServerOptions{
Port: 8080,
Advanced: meta.TCPAdvancedOptions{
KeepAlivePeriod: 60 * time.Second,
},
}certManager := createCertManager() // Your certificate manager
options := meta.TCPServerOptions{
Port: 8443,
CertManager: certManager,
}options := meta.TCPConnectionOptions{
Host: "example.com",
Port: 443,
CertManager: certManager,
}options := meta.TCPConnectionOptions{
Host: "self-signed-server.local",
Port: 443,
CertManager: certManager,
InsecureSkipVerify: true, // Don't verify server certificate
}// Server: all connections send messages to "connection_manager"
serverOpts := meta.TCPServerOptions{
Port: 8080,
ProcessPool: []gen.Atom{"connection_manager"},
}
// Client: this connection sends messages to "http_handler"
clientOpts := meta.TCPConnectionOptions{
Host: "example.com",
Port: 80,
Process: "http_handler",
}// Inspect server
serverInfo, _ := process.InspectMeta(serverID)
// Returns: map[string]string{"listener": "0.0.0.0:8080"}
// Inspect connection
connInfo, _ := process.InspectMeta(connID)
// Returns: map[string]string{
// "local": "192.168.1.10:8080",
// "remote": "192.168.1.20:54321",
// "process": "tcp_worker_3",
// "bytes in": "1048576",
// "bytes out": "524288",
// }type ConnectionManager struct {
act.Actor
connections map[gen.Alias]*ConnectionInfo
}
type ConnectionInfo struct {
remoteAddr net.Addr
startTime time.Time
}
func (m *ConnectionManager) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case meta.MessageTCPConnect:
m.connections[msg.ID] = &ConnectionInfo{
remoteAddr: msg.RemoteAddr,
startTime: time.Now(),
}
m.Log().Info("connection #%d: %s", len(m.connections), msg.RemoteAddr)
case meta.MessageTCPDisconnect:
info := m.connections[msg.ID]
duration := time.Since(info.startTime)
m.Log().Info("connection closed: %s (duration: %s)",
info.remoteAddr, duration)
delete(m.connections, msg.ID)
}
return nil
}type ProtocolHandler struct {
act.Actor
connections map[gen.Alias]*ProtocolState
}
type ProtocolState struct {
state int // Current state in protocol state machine
buffer []byte
}
func (h *ProtocolHandler) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCPConnect:
h.connections[m.ID] = &ProtocolState{state: STATE_INITIAL}
case meta.MessageTCP:
state := h.connections[m.ID]
state.buffer = append(state.buffer, m.Data...)
// Process buffered data according to current state
for {
complete, nextState := h.processState(m.ID, state)
if !complete {
break
}
state.state = nextState
}
bufferPool.Put(m.Data)
}
return nil
}func (m *ConnectionManager) broadcastMessage(data []byte) {
for connID := range m.connections {
m.Send(connID, meta.MessageTCP{Data: data})
}
}// WRONG: Connection state leaked
func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCPConnect:
w.connections[m.ID] = &State{}
case meta.MessageTCP:
w.connections[m.ID].process(m.Data)
// No MessageTCPDisconnect handler!
}
return nil
}// WRONG: Protocol state corrupted
options := meta.TCPServerOptions{
ProcessPool: []gen.Atom{"worker_pool"}, // Don't use act.Pool!
}// WRONG: Blocks actor, stalls other connections
func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCP:
// Slow database query
result := w.db.Query("SELECT * FROM large_table")
w.Send(m.ID, meta.MessageTCP{Data: result})
}
return nil
}// WRONG: Buffer leaked
func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCP:
// Store data, never return buffer
w.dataQueue = append(w.dataQueue, m.Data)
}
return nil
}
// CORRECT: Copy if storing
func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessageTCP:
copied := make([]byte, len(m.Data))
copy(copied, m.Data)
w.dataQueue = append(w.dataQueue, copied)
bufferPool.Put(m.Data)
}
return nil
}Unified monitoring: HTTP requests visible as actor messages in system introspection
ServeHTTP() executed by http.Server goroutinesServeHTTP() sends meta.MessageWebRequest to worker actor using Send()
ServeHTTP() blocks on <-ctx.Done()
Worker actor receives message in its mailbox, processes it in HandleMessage()
Worker writes HTTP response to ResponseWriter, calls Done()
Done() cancels context, unblocking ServeHTTP()
ServeHTTP() returns, connection goroutine completes
// Same call works regardless of actor location
result, err := node.Call(
gen.ProcessID{Name: "user-service", Node: "backend@node1"},
request,
)func (a *APIServer) handleGetUser(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
// Route requests for the same user to the same node
// This improves cache locality - user data stays hot
nodeID := consistentHash(userID, a.clusterSize)
targetNode := fmt.Sprintf("backend@node%d", nodeID)
result, err := a.node.Call(
gen.ProcessID{Name: "user-service", Node: targetNode},
GetUserRequest{ID: userID},
)
// handle result...
}func (a *APIServer) handleRequest(w http.ResponseWriter, r *http.Request) {
// Application discovery requires central registrar (etcd or Saturn)
// See: networking/service-discovering.md
routes, err := a.node.Network().ResolveApplication("user-service")
if err != nil || len(routes) == 0 {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
// Select node based on weight, load, health, proximity
target := a.selectNode(routes)
result, err := a.node.Call(
gen.ProcessID{Name: "user-service", Node: target.Node},
GetUserRequest{ID: r.PathValue("id")},
)
// handle result...
}
func (a *APIServer) selectNode(routes []gen.ApplicationRoute) gen.ApplicationRoute {
// Weighted random selection
totalWeight := 0
for _, r := range routes {
totalWeight += r.Weight
}
pick := rand.Intn(totalWeight)
for _, r := range routes {
pick -= r.Weight
if pick < 0 {
return r
}
}
return routes[0]
}// Standard middleware works
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !isAuthorized(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
mux.Handle("/api/", authMiddleware(http.HandlerFunc(a.handleAPI)))type WebService struct {
act.Actor
}
func (w *WebService) Init(args ...any) error {
// Spawn worker that will handle HTTP requests
_, err := w.SpawnRegister("web-worker",
func() gen.ProcessBehavior { return &WebWorker{} },
gen.ProcessOptions{},
)
if err != nil {
return err
}
// Create HTTP multiplexer
mux := http.NewServeMux()
// Create handler meta-process pointing to worker
handler := meta.CreateWebHandler(meta.WebHandlerOptions{
Worker: "web-worker",
RequestTimeout: 5 * time.Second,
// Own the response for a request that never reached a worker.
// Nil answers plain text, which a JSON client cannot read.
Refusal: func(w http.ResponseWriter, r *http.Request, status int, reason error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": reason.Error()})
},
})
// Spawn handler meta-process
handlerID, err := w.SpawnMeta(handler, gen.MetaOptions{})
if err != nil {
return err
}
// Register handler with mux (handler implements http.Handler)
// Standard middleware works - handler is just http.Handler
mux.Handle("/", authMiddleware(rateLimitMiddleware(handler)))
// Create web server meta-process
server, err := meta.CreateWebServer(meta.WebServerOptions{
Host: "localhost",
Port: 8080,
Handler: mux,
})
if err != nil {
return err
}
// Spawn server meta-process
serverID, err := w.SpawnMeta(server, gen.MetaOptions{})
if err != nil {
server.Terminate(err)
return err
}
w.Log().Info("HTTP server listening on :8080 (server=%s, handler=%s)",
serverID, handlerID)
return nil
}503
ErrHandlerNotInitialized
a request arrived before the meta finished starting
503
ErrHandlerTerminated
type WebWorker struct {
act.Actor
}
func (w *WebWorker) HandleMessage(from gen.PID, message any) error {
request, ok := message.(meta.MessageWebRequest)
if !ok {
return nil
}
defer request.Done() // Always call Done to unblock ServeHTTP
// Process HTTP request
switch request.Request.Method {
case "GET":
user := w.getUserFromDB(request.Request.URL.Query().Get("id"))
json.NewEncoder(request.Response).Encode(user)
case "POST":
var order Order
json.NewDecoder(request.Request.Body).Decode(&order)
w.createOrder(order)
request.Response.WriteHeader(http.StatusCreated)
default:
http.Error(request.Response, "Method not supported", http.StatusMethodNotAllowed)
}
return nil
}type WebWorkerPool struct {
act.Pool
}
func (p *WebWorkerPool) Init(args ...any) (act.PoolOptions, error) {
return act.PoolOptions{
PoolSize: 20, // 20 concurrent workers
WorkerMailboxSize: 10, // Each worker queues up to 10 requests
WorkerFactory: func() gen.ProcessBehavior { return &WebWorker{} },
}, nil
}
func (w *WebService) Init(args ...any) error {
// Spawn pool with registered name
_, err := w.SpawnRegister("web-worker",
func() gen.ProcessBehavior { return &WebWorkerPool{} },
gen.ProcessOptions{},
)
if err != nil {
return err
}
handler := meta.CreateWebHandler(meta.WebHandlerOptions{
Worker: "web-worker",
})
_, err = w.SpawnMeta(handler, gen.MetaOptions{})
// rest of setup...
}// Chat room broadcasts to all connected clients
for _, connAlias := range room.connections {
room.Send(connAlias, ChatMessage{From: sender, Text: text})
}
// Game server on node1 pushes update to player connection on node2
gameServer.Send(playerConnAlias, StateUpdate{HP: hp, Position: pos})
// Backend actor pushes notification to user's browser
backend.Send(userConnAlias, Notification{Text: "Task completed"})MessagePortTerminateBy default, Port operates in text mode. It reads stdout line by line and sends each line as MessagePortText. It reads stderr the same way and sends errors as MessagePortError.
Text mode uses bufio.Scanner internally, which splits input by lines (newline delimiter). You can customize the splitting logic:
Text mode is simple and works well for line-oriented protocols: command-response pairs, JSON-per-line, log output, or any text-based format. But it's not suitable for binary protocols.
For binary protocols (Protobuf, MessagePack, custom framing), enable binary mode:
In binary mode, the Port reads raw bytes from stdout and sends them as MessagePortData. You send binary data using MessagePortData messages:
The Port reads up to ReadBufferSize bytes at a time from stdout and sends each chunk as MessagePortData. There's no framing or splitting - you receive raw bytes as the Port reads them. If your protocol has message boundaries, you must track them yourself.
Stderr is always processed in text mode, even when binary mode is enabled. Stderr messages arrive as MessagePortError.
Reading raw bytes means dealing with partial messages. A 1KB message might arrive as three separate MessagePortData messages (512 bytes, 400 bytes, 88 bytes), or multiple messages might arrive together in one chunk. You need to buffer, reassemble, and detect message boundaries.
Chunking solves this by automatically framing messages. Instead of receiving raw bytes, you receive complete chunks - one MessagePortData per message, properly framed.
If every message is the same size, use fixed-length chunking:
The Port buffers stdout until it has 256 bytes, then sends them as one MessagePortData. If a read returns 512 bytes, you receive two MessagePortData messages (256 bytes each). If a read returns 100 bytes, the Port waits for more data before sending.
This is efficient for fixed-size protocols: binary structs, fixed-width encodings, or any format where every message has the same length.
Most binary protocols use variable-length messages with a header that specifies the length. Chunking can parse these headers automatically:
This configuration matches a protocol where:
Every message starts with a 4-byte header
The header contains a 4-byte big-endian integer (bytes 0-3)
The integer specifies the payload length (header not included)
Messages are: [4-byte length][payload]
The Port reads the header, extracts the length, waits for the full payload to arrive, then sends the complete message (header + payload) as MessagePortData.
Example protocol:
With the configuration above, you receive two MessagePortData messages:
First: 14 bytes (4-byte header + 10-byte payload)
Second: 260 bytes (4-byte header + 256-byte payload)
If the external program writes both messages at once (274 bytes total), the Port automatically splits them. If the program writes slowly (header arrives, then payload arrives later), the Port waits for the complete message before sending.
Header length options:
HeaderLengthSize can be 1, 2, or 4 bytes. All lengths are big-endian. The Port reads the header, extracts the length value, computes the total message size (adding header size if HeaderLengthIncludesHeader is false), and buffers until the complete message arrives.
MaxLength protection:
If the header specifies a length exceeding MaxLength, the Port terminates with gen.ErrTooLarge. This protects against malformed messages or malicious programs that claim a message is 4GB (causing memory exhaustion).
Set MaxLength based on your protocol's reasonable maximum. Leave it zero for no limit (use cautiously).
The Port allocates buffers for reading stdout. By default, each read allocates a new buffer, which is sent in MessagePortData and becomes garbage when you're done with it. For high-throughput ports, this causes GC pressure.
Use a buffer pool to reuse buffers:
The Port gets buffers from the pool when reading stdout. When you receive MessagePortData, the Data field is a buffer from the pool. You must return it to the pool when done:
If you forget to return buffers, the pool will allocate new ones, defeating the purpose. If you return a buffer and then access it later, you'll get corrupted data (the buffer is reused by the Port for the next read).
When you send MessagePortData to write to stdin, the Port automatically returns the buffer to the pool after writing (if a pool is configured). You don't need to do anything:
Buffer pools are critical for high-throughput scenarios. For low-volume ports (a few messages per second), the GC overhead is negligible - skip the pool for simplicity.
Some external programs expect periodic input to stay alive. If stdin goes silent for too long, they timeout or disconnect. You could send keepalive messages from your actor (with timers), but that's tedious and error-prone.
Enable automatic keepalive:
The Port wraps stdin with a keepalive flusher. If nothing is written for WriteBufferKeepAlivePeriod, it automatically sends WriteBufferKeepAlive bytes. This keeps the connection alive without any action from your actor.
The keepalive message can be anything: a null byte, a specific protocol message, a ping command. The external program receives it as normal stdin input. Design your protocol to ignore or handle keepalive messages.
Keepalive is only available in binary mode. In text mode, you need to send keepalive messages manually.
The external program inherits environment variables based on your configuration:
EnableEnvOS: Includes the operating system's environment. This gives the program access to PATH, HOME, USER, and other system variables. Useful when the program needs to find other executables or access user-specific paths.
EnableEnvMeta: Includes environment variables from the meta-process (inherited from its parent actor). Meta-processes share their parent's environment. If the parent has MY_VAR=value, the Port's external program sees MY_VAR=value too.
Env: Custom variables specific to this Port. These are always included regardless of the other flags.
Order of precedence (if duplicate names):
Custom Env (highest priority)
Meta-process environment
OS environment (lowest priority)
By default, all Port messages (start, terminate, data, errors) go to the parent process - the actor that spawned the Port. For single-port scenarios, this is fine. For multiple ports or advanced architectures, you want routing:
All Port messages are sent to the process registered as data_handler. This enables:
Worker pools:
The Port sends all messages to a pool, which distributes them across workers. Multiple ports can share the same pool for load balancing.
Centralized handlers:
Both ports send messages to python_manager, which coordinates multiple Python scripts.
Distinguishing ports with tags:
The Tag field appears in all Port messages. The manager uses it to distinguish which port sent the message:
If Process is empty or not registered, messages go to the parent process.
Messages you receive from the Port:
MessagePortStart - Port started successfully, external program is running:
Sent once after the external program starts. Use this to send initialization commands.
MessagePortTerminate - Port stopped, external program exited:
Sent when the external program terminates (exit, crash, killed) or when you terminate the Port. After this, the Port is dead - you cannot send it more messages.
MessagePortText - Line from stdout (text mode only):
Sent for each line read from stdout in text mode. The delimiter (newline or custom) is stripped from Text.
MessagePortData - Binary data from stdout (binary mode only):
In binary mode without chunking, Data contains whatever bytes the Port read (up to ReadBufferSize). With chunking, Data contains one complete chunk.
If ReadBufferPool is configured, Data is from the pool - return it when done.
MessagePortError - Line from stderr (always text mode):
Sent for each line read from stderr. Stderr is always processed in text mode, even when binary mode is enabled for stdout.
Messages you send to the Port:
MessagePortText - Send text to stdin (text mode):
Writes Text to stdin. Newlines are not added automatically - include them if your protocol needs them.
MessagePortData - Send binary data to stdin (binary mode):
Writes Data to stdin. If ReadBufferPool is configured, the Port returns the buffer to the pool after writing. Don't use the buffer after sending.
When the external program exits (normally or crash), the Port sends MessagePortTerminate and terminates itself. The Port also kills the external program if:
The Port is terminated (you call process.SendExitMeta(portID, reason) - a Port is addressed by gen.Alias, and SendExit takes a gen.PID)
The Port's parent terminates (cascading termination)
An error occurs reading stdout (broken pipe, I/O error)
The Port calls Kill() on the child process and waits for it to exit. This ensures cleanup happens even if the program is misbehaving.
Stderr is read in a separate goroutine. This means stderr messages can arrive after MessagePortTerminate if the program wrote to stderr just before exiting. Design your actor to handle this ordering.
Port supports inspection for debugging:
A meta process is inspected by its alias through InspectMeta, not by a Call: the request goes down the meta's system queue to its HandleInspect, which a Call never reaches. gen.Node carries the same method for callers that are not processes.
Returns a map with Port status:
Use this for monitoring, debugging, or displaying Port status in management UIs.
Pattern: Request-response wrapper
Wrap a Port to provide synchronous Call semantics. Useful for RPC-style protocols.
Pattern: Supervised restart
Supervise the actor that spawns ports. If the actor crashes, the supervisor restarts it, which re-spawns ports. Ports inherit parent lifecycle - when the actor terminates, all its ports terminate.
Pattern: bounding slow processing of port output
The controller that owns the port should not do slow work in its own handler, and it must not reach for a semaphore or a goroutine to avoid that. Both break the actor model, and this chapter's own page says why: a blocking send inside a callback stalls the actor's single dispatch loop, including the stdin writes the port needs, and a goroutine calling c.processData gives two goroutines concurrent access to the controller's state.
Hand the work to a pool instead. The pool's PoolSize bounds concurrency and WorkerMailboxSize bounds the burst, which is the backpressure the semaphore was reaching for:
Two things to decide with this shape. A ReadBufferPool buffer must be returned by whoever finishes with it - that is now the worker, after processData, and never twice. And when every worker mailbox is full the pool drops the message rather than queueing it (see ), so size the pool for the port's real output rate and watch ergo:messages_unhandled.
Pitfall: Forgetting to return buffers
Pool buffers are reused. If you store them, they'll be overwritten by future reads. Copy data if you need to keep it.
Pitfall: Blocking on stdin writes
If the external program stops reading stdin (buffer full, process blocked), the Port blocks inside that write.
What it does not block is stdout. A meta process runs two goroutines: one handles its mailbox, and one runs Start, which for a Port is the stdout reader that sends you MessagePortText / MessagePortData. The stdin writes live in HandleMessage, on the mailbox goroutine, so a stalled write stops further stdin writes and nothing else - stdout keeps arriving, and the queued outbound data piles up in the Port's mailbox instead.
That is a leak of memory and of latency rather than a deadlock, and it is silent: the sender's Send returns immediately every time. Design your protocol so the external program never stops reading stdin, and use flow control or chunking to prevent overflows.
Pitfall: Ignoring MessagePortError
Stderr messages arrive as MessagePortError. If you don't handle them, warnings and errors from the external program are lost. Always handle stderr or explicitly decide to ignore it.
Pitfall: Not handling MessagePortTerminate
After MessagePortTerminate, the Port is dead. Sending messages returns errors. Handle termination: restart the Port, fail gracefully, or terminate your actor.
Port meta-processes enable clean integration with external programs. They handle process management, I/O buffering, protocol framing, and lifecycle coordination - letting you focus on the protocol logic while maintaining the actor model's isolation and simplicity.
type Controller struct {
act.Actor
portID gen.Alias
}
func (c *Controller) Init(args ...any) error {
// Define port options
options := meta.PortOptions{
Cmd: "python3",
Args: []string{"processor.py", "--mode=batch"},
Env: map[gen.Env]string{
"WORKER_ID": "worker-1",
},
}
// Create port behavior
portBehavior, err := meta.CreatePort(options)
if err != nil {
return fmt.Errorf("failed to create port: %w", err)
}
// Spawn as meta-process
portID, err := c.SpawnMeta(portBehavior, gen.MetaOptions{})
if err != nil {
return fmt.Errorf("failed to spawn port: %w", err)
}
c.portID = portID
c.Log().Info("spawned port for %s (id: %s)", options.Cmd, portID)
return nil
}the handler meta is gone
503
ErrHandlerNotReady
the handler is up but not yet serving
502
ErrWorkerUnreachable
the Send to the worker failed - wrong name, or the worker is not running
504
ErrWorkerTimeout
the message was accepted but nothing answered within RequestTimeout
func (c *Controller) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortStart:
c.Log().Info("port started: %s", m.ID)
// Send initial command
c.Send(m.ID, meta.MessagePortText{Text: "INIT worker-1\n"})
case meta.MessagePortText:
// Received line from stdout
c.Log().Info("port output: %s", m.Text)
c.processOutput(m.Text)
case meta.MessagePortError:
// Received line from stderr
c.Log().Warning("port error: %s", m.Error)
case meta.MessagePortTerminate:
c.Log().Info("port terminated: %s", m.ID)
// Restart or cleanup
}
return nil
}
func (c *Controller) processCommand(cmd string) {
// Send command to external program
c.Send(c.portID, meta.MessagePortText{
Text: cmd + "\n",
})
}options := meta.PortOptions{
Cmd: "processor",
// Custom split function for stdout
SplitFuncStdout: func(data []byte, atEOF bool) (advance int, token []byte, err error) {
// Find null-terminated strings instead of newlines
if i := bytes.IndexByte(data, 0); i >= 0 {
return i + 1, data[:i], nil
}
if atEOF && len(data) > 0 {
return len(data), data, nil
}
return 0, nil, nil
},
// Custom split function for stderr (optional)
SplitFuncStderr: bufio.ScanWords, // Split stderr by words
}options := meta.PortOptions{
Cmd: "binary-processor",
Binary: meta.PortBinaryOptions{
Enable: true,
ReadBufferSize: 16384, // 16KB read buffer
},
}func (c *Controller) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortStart:
// Send binary request
request := encodeRequest("GET", "/data")
c.Send(m.ID, meta.MessagePortData{Data: request})
case meta.MessagePortData:
// Received binary data from stdout
response := decodeResponse(m.Data)
c.handleResponse(response)
}
return nil
}options := meta.PortOptions{
Cmd: "fixed-protocol",
Binary: meta.PortBinaryOptions{
Enable: true,
ReadChunk: meta.ChunkOptions{
Enable: true,
FixedLength: 256, // Every message is exactly 256 bytes
},
},
}options := meta.PortOptions{
Cmd: "length-prefix-protocol",
Binary: meta.PortBinaryOptions{
Enable: true,
ReadChunk: meta.ChunkOptions{
Enable: true,
// Header structure
HeaderSize: 4, // 4-byte header
HeaderLengthPosition: 0, // Length starts at byte 0
HeaderLengthSize: 4, // Length is a 4-byte integer
// Does length include the header?
HeaderLengthIncludesHeader: false, // Length is payload only
// Safety limit
MaxLength: 1048576, // Max 1MB per message
},
},
}Message 1: [0x00 0x00 0x00 0x0A] [10 bytes of payload]
Message 2: [0x00 0x00 0x01 0x00] [256 bytes of payload]// Length is in bytes 2-3 (2-byte length at offset 2)
HeaderLengthPosition: 2,
HeaderLengthSize: 2,
// Length includes the header (length = total message size)
HeaderLengthIncludesHeader: true,
// Protocol: [type][flags][length-MSB][length-LSB][payload]
// byte0 byte1 byte2 byte3 bytes 4+MaxLength: 65536, // Reject messages larger than 64KBbufferPool := &sync.Pool{
New: func() any {
return make([]byte, 16384)
},
}
options := meta.PortOptions{
Cmd: "high-throughput",
Binary: meta.PortBinaryOptions{
Enable: true,
ReadBufferSize: 16384,
ReadBufferPool: bufferPool,
},
}func (c *Controller) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortData:
// Process the data
c.processData(m.Data)
// Return buffer to pool
bufferPool.Put(m.Data)
}
return nil
}buf := bufferPool.Get().([]byte)
// Fill buf with data
c.Send(portID, meta.MessagePortData{Data: buf})
// Port returns buf to pool after writingoptions := meta.PortOptions{
Cmd: "keepalive-required",
Binary: meta.PortBinaryOptions{
Enable: true,
WriteBufferKeepAlive: []byte{0x00}, // Send null byte
WriteBufferKeepAlivePeriod: 5 * time.Second,
},
}options := meta.PortOptions{
Cmd: "processor",
// Enable OS environment variables (PATH, HOME, etc)
EnableEnvOS: true,
// Enable meta-process environment variables
EnableEnvMeta: true,
// Custom environment variables
Env: map[gen.Env]string{
"WORKER_ID": "worker-1",
"LOG_LEVEL": "debug",
},
}options := meta.PortOptions{
Cmd: "worker",
Process: "data_handler", // Send all messages to this registered process
}options := meta.PortOptions{
Cmd: "processor",
Process: "worker_pool", // act.Pool actor
}options := meta.PortOptions{
Cmd: "python3",
Args: []string{"script1.py"},
Process: "python_manager",
}
options2 := meta.PortOptions{
Cmd: "python3",
Args: []string{"script2.py"},
Process: "python_manager",
}options1 := meta.PortOptions{
Cmd: "worker",
Tag: "input-processor",
Process: "manager",
}
options2 := meta.PortOptions{
Cmd: "worker",
Tag: "output-formatter",
Process: "manager",
}func (m *Manager) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case meta.MessagePortData:
switch msg.Tag {
case "input-processor":
m.handleInput(msg.Data)
case "output-formatter":
m.handleOutput(msg.Data)
}
}
return nil
}type MessagePortStart struct {
ID gen.Alias // Port's meta-process ID
Tag string // Tag from PortOptions
}type MessagePortTerminate struct {
ID gen.Alias
Tag string
}type MessagePortText struct {
ID gen.Alias
Tag string
Text string // One line (delimiter removed)
}type MessagePortData struct {
ID gen.Alias
Tag string
Data []byte // Raw bytes or complete chunk
}type MessagePortError struct {
ID gen.Alias
Tag string
Error error // Line from stderr as an error
}c.Send(portID, meta.MessagePortText{
Text: "COMMAND arg1 arg2\n",
})c.Send(portID, meta.MessagePortData{
Data: encodedMessage,
})result, err := process.InspectMeta(portID)map[string]string{
"tag": "worker-1",
"cmd": "/usr/bin/python3",
"args": "[script.py --mode=batch]",
"pid": "12345", // OS process ID
"binary": "true", // Binary mode enabled
"binary.read_chunk": "true", // Chunking enabled
"env": "[WORKER_ID=worker-1]",
"pwd": "/path/to/working/dir",
"bytesIn": "1048576", // Bytes read from stdout
"bytesOut": "524288", // Bytes written to stdin
}type PortWrapper struct {
act.Actor
portID gen.Alias
pending map[gen.Ref]gen.PID
sequence uint64
}
func (w *PortWrapper) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
// Generate unique request ID
reqID := atomic.AddUint64(&w.sequence, 1)
// Store caller
w.pending[ref] = from
// Send to port with ID
w.Send(w.portID, meta.MessagePortText{
Text: fmt.Sprintf("%d:%s\n", reqID, request),
})
// Will respond asynchronously
return nil, nil
}
func (w *PortWrapper) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortText:
// Parse response: "reqID:result"
parts := strings.SplitN(m.Text, ":", 2)
reqID, result := parts[0], parts[1]
// Find pending caller
for ref, caller := range w.pending {
if matchesRequestID(ref, reqID) {
w.SendResponse(caller, ref, result)
delete(w.pending, ref)
break
}
}
}
return nil
}type PortSupervisor struct {
act.Supervisor
}
func (s *PortSupervisor) Init(args ...any) (act.SupervisorSpec, error) {
return act.SupervisorSpec{
Children: []act.SupervisorChildSpec{
{
Name: "port_manager",
Factory: createPortManager,
},
},
Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyPermanent,
Intensity: 5,
Period: 10,
},
}, nil
}// In the controller's Init: a pool of workers that does the slow part
func (c *Controller) Init(args ...any) error {
pid, err := c.SpawnRegister("port_workers", createPortPool, gen.ProcessOptions{})
if err != nil {
return err
}
c.workers = pid
return nil
}
// The controller only routes. No goroutines, no blocking.
func (c *Controller) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortData:
return c.Send(c.workers, m)
}
return nil
}
// The worker does the work, one chunk at a time, on its own goroutine
func (w *PortWorker) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortData:
w.processData(m.Data)
}
return nil
}// WRONG: Buffer leaked
func (c *Controller) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortData:
c.dataQueue = append(c.dataQueue, m.Data) // Stored, never returned!
}
return nil
}
// CORRECT: Copy if you need to store
func (c *Controller) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortData:
copied := make([]byte, len(m.Data))
copy(copied, m.Data)
c.dataQueue = append(c.dataQueue, copied)
bufferPool.Put(m.Data) // Return original
}
return nil
}// Port writes are blocking
c.Send(portID, meta.MessagePortData{Data: largeBuffer})
// ^ This Send doesn't block, but the Port's write to stdin might// WRONG: Stderr ignored
func (c *Controller) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case meta.MessagePortData:
c.process(m.Data)
// No case for MessagePortError!
}
return nil
}// WRONG: Port terminated, but actor keeps trying to use it
func (c *Controller) processData(data []byte) {
c.Send(c.portID, meta.MessagePortData{Data: data})
// ^ Fails if port terminated
}Sometimes the right worker for a message depends on the message: payments to the payments service, shipments to the shipments service, per-user requests to the shard that owns the user's state. A pool of identical workers picks the next worker in order without looking at what arrived, so this decision has to live somewhere else.
act.Router is that somewhere. It owns a set of named routes (or none) and asks your code which route should handle each incoming message. Async sends go through RouteMessage, sync calls through RouteCall. The Router resolves the returned name to a local PID (own routes first, then the node's process registry) and forwards the message. The original sender is preserved, so the destination worker responds directly to whoever asked. Routes are stable through worker restarts, so hash-based affinity and content-based dispatch stay coherent across failures.
Embed act.Router in your struct and implement the act.RouterBehavior interface:
type RouterBehavior interface {
gen.ProcessBehavior
Init(args ...any) (RouterOptions, error)
Terminate(reason error)
RouteMessage(from gen.PID, message any) gen.Atom
RouteCall(from gen.PID, ref gen.Ref, request any) gen.Atom
HandleMessage(from gen.PID, message any) error
HandleCall(from gen.PID, ref gen.Ref, request any) (any, error)
HandleEvent(message gen.MessageEvent) error
HandleInspect(from gen.PID, item ...string) map[string]string
}Init, RouteMessage, and RouteCall are mandatory. act.Router deliberately does not provide default implementations for the two routing callbacks: a router that doesn't route is useless, so the compiler refuses to build one. If you only handle one direction (commands but not queries, or queries but not commands), implement the other in a single line returning act.RouteDiscard.
The remaining callbacks have defaults: HandleMessage, HandleCall, and HandleEvent log a warning and return nil; HandleInspect returns the built-in routing statistics; Terminate is a no-op.
A minimal router:
The router spawns every owned route during initialization. Initialization is atomic: if any route can't start, the router doesn't start at all. There is no partial-state condition the operator has to clean up.
A route's factory can return anything: a regular actor, a pool, a supervisor, another router. Composition is the main reason act.Router is small. Restart policy, mailbox preservation, pool fan-out belong to the primitives that already do them well; the router just owns named slots and forwards.
For each incoming message the router calls a routing callback, takes the returned name, and finds the actor it refers to:
Owned routes first. If the name matches a route declared in Init or added later via AddRoute, the router forwards to that route's worker.
Local registry fallback. Otherwise the router looks up the name in the node's process registry. A locally registered process with that name receives the message.
Otherwise the message becomes a MessageRouteFailed{Name, From, Message, Reason}
Returning act.RouteDiscard (the empty atom) skips the resolution path entirely. The router increments its discarded counter; the sender receives nothing. For synchronous calls, gen.ErrDiscarded is returned to the caller.
Forwarding preserves the original From and Ref. The worker sees the message as if the sender had targeted it directly:
The same applies to calls: the worker's HandleCall sees the original caller's PID and ref. SendResponse goes directly to the caller; the router is not in the response path.
RouteMessage and RouteCall are separate callbacks because async sends and sync calls usually need different routing. In CQRS terms:
RouteMessage routes commands (state-mutating writes). Typically dispatched to write-side aggregates sharded by aggregate ID. Order matters per shard; the router's stable name-to-worker mapping keeps the shard owner consistent across restarts.
RouteCall routes queries (reads). Typically dispatched to read-model projections or replicated query workers. Affinity matters less here; you can load-balance across replicas with the same router or send everything to a single read-side process.
A CQRS router can route commands by aggregate_id % N and queries by view type, with completely separate logic for the two directions:
A router can own its workers, route only to externally registered processes, or mix both.
Owned routes. Routes declared in Init (or added later via AddRoute) are owned by the router. The router spawns them and brings them back if they die. They appear in Routes().
Free router. Return RouterOptions{} from Init with no Routes field. The router starts with zero workers. Every name returned by RouteMessage / RouteCall resolves through the local registry. This is the gateway pattern: the router dispatches, but the actors it dispatches to are supervised elsewhere in the system.
Mixed. Owned routes and registry fallback coexist. The router checks its own routes first; anything not found there goes through the registry. You can start a free router and add owned routes later, or start with owned routes and let RouteMessage occasionally return registered external names.
Owned routes are kept alive automatically. When a route's worker terminates, the router spawns a replacement from the same spec. The replacement takes the same slot, keeping the route's name stable. The sender's next message reaches the new worker through the same name. The restarts counter increments.
If the replacement itself fails to spawn, the router logs the error and leaves the slot empty rather than crashing. The next message routed to that slot retries the spawn. If retries keep failing, the router accumulates failed counts; senders see MessageRouteFailed events on the admin path.
Restart policy is intentionally minimal: the router keeps trying. There is no intensity limit, no period, no escalation. If you need a strict restart contract (rate limits, mailbox preservation across restarts, controlled escalation when limits are exceeded), the worker has to live under its own Supervisor outside the router. Don't put a Supervisor directly in a router route. The router would forward messages to the Supervisor process, which has nothing useful to do with them; the actual worker is the Supervisor's child and is addressable only through the node registry. Instead, supervise the worker (or a pool of workers per shard) separately, let the supervisor register the target under a known name, and let the router resolve that name through the registry. The Sharded workers with capacity pattern below shows the canonical layout.
DisableRoute, ReplaceRoute, and RemoveRoute all terminate the current worker before completing. Termination is asynchronous: the router records the intended action and waits for the worker's exit before finalizing the change. During that wait the route is in a pending state.
While a route is pending, every management call on that route returns gen.ErrBusy. Forwarding behavior also adjusts:
pending == Disable causes forwarding to fail with gen.ErrDisabled.
pending == Remove causes forwarding to fail with gen.ErrNoRoute.
pending == Replace
If a route is already dead when you call DisableRoute, ReplaceRoute, or RemoveRoute, the operation completes synchronously and never enters pending state.
A router exposes management methods on the embedded *act.Router type. Callable from inside the router's callbacks or from outside, while the router is running.
Routes() and Route(name) return immutable snapshots of the routing table. Each RouterRouteInfo carries the route name, the current PID (empty if not running), the Disabled flag, and the Pending state.
AddRoute appends a new owned route and spawns it. Returns act.ErrRouteDuplicate if the name is already registered with the router. Empty name or nil factory return descriptive errors. If the spawn fails, the entry is not added.
RemoveRoute tears down an owned route. The worker is asked to shut down gracefully; once it exits, the entry is dropped. Removing an unknown name is a no-op (returns nil), so callers can be idempotent.
DisableRoute takes a route offline. The worker terminates, the route is marked disabled, the slot is preserved. Subsequent messages routed to this name fail with gen.ErrDisabled. EnableRoute reverses this: clears the flag and spawns a fresh worker from the stored spec.
ReplaceRoute swaps the factory and args of an existing route. If the route is running, the current worker is terminated; the new spec is spawned after the exit. If the route is dead, the swap and spawn happen synchronously. If the route is disabled, the spec is swapped but no worker is spawned; the new spec takes effect on the next EnableRoute.
RespawnRoute is for manually waking a dead route after a transient spawn failure has been fixed. It returns act.ErrRouteRunning if the worker is already alive, gen.ErrDisabled if the route is admin-disabled, or gen.ErrBusy if a pending operation is in flight.
All mutating methods return gen.ErrNotAllowed if the router itself isn't running (terminated, killed). Read methods (Routes, Route) work in any state.
Mutating operations (AddRoute, RemoveRoute, DisableRoute, EnableRoute, ReplaceRoute, RespawnRoute) return a non-nil error if the change could not be applied. On error the route's state is unchanged and the call can be retried.
Routing callbacks fire for messages arriving with normal priority. Messages with high or maximum priority skip routing and reach HandleMessage / HandleCall on the router itself:
The priority queue is the admin channel. Use it for runtime management (scaling routes, reconfiguration, statistics queries) that the router itself should answer rather than forward to a worker.
HandleMessage also receives MessageRouteFailed for asynchronous routing failures. The router delivers it synchronously from its own routing path. Return non-nil from HandleMessage to terminate the router on the failure; return nil to keep running:
HandleCall on the admin path follows the same convention. Return a non-nil result to respond synchronously, return nil to defer the response via SendResponse from elsewhere, or return a non-nil reason to terminate the router.
MessageRouteFailed carries the routing decision that could not be fulfilled:
Common reasons:
gen.ErrProcessUnknown: the name resolved to nothing (no route, no registry entry, or the resolved process died between lookup and forward).
gen.ErrProcessMailboxFull: the target's mailbox is full and not accepting more.
gen.ErrDisabled: the target route is owned by this router and currently disabled or mid-disable.
For synchronous calls there is no MessageRouteFailed. The same reason is returned to the caller directly as the call's error.
Default HandleInspect returns a flat key-value map with router-level counters and per-route entries:
All of these keys use the reserved ergo: prefix. A HandleInspect you implement is merged on top of them, so your fields are added beside these rather than replacing the set - and one of these is overridden only if you name it with the prefix.
Override HandleInspect to add fields specific to your routing logic.
The router's flexibility comes from composition. A slot's factory is typically a regular actor or another router; pool-per-shard and supervised workers live as siblings of the router, addressed through the node registry rather than nested under the router. The following patterns cover the common production layouts.
The simplest case. Slots host different worker types; RouteMessage switches by message type.
Senders address the router, the router dispatches by content. Use this when entry-point cardinality matters (one named PID for the dispatcher) and workers are heterogeneous.
When state is partitioned by key, every request for a key must reach the same worker. Use a fixed-size route table and hash-based routing.
The same key always lands on the same worker. Routes are stable: the worker behind shard:7 can die and respawn, but shard:7 continues to be the address for that shard's traffic.
Workers in this pattern are owned by the Router directly: they aren't registered in the node registry, so only the Router can address them. The Router replaces a dead worker eagerly (or lazily on next forward), but the in-flight mailbox of the dying worker is lost. For real shards with capacity per shard and pool-managed worker lifecycle, see the next pattern.
For real shards with multiple workers per shard, make each shard an act.Pool registered under the shard's name. The Router stays out of the supervision tree: a single top-level supervisor owns all the pools and the router as siblings. Senders address only the router; the router resolves the shard name through the node registry to the pool; the pool round-robins to one of its workers.
Message flow on a Send(routerPID, msg):
Router's RouteMessage returns "shard:N" (hash of the message's key).
Router's resolver doesn't find "shard:N" in its own routes (the router is free) and falls back to the node registry. The registry has "shard:N" registered by SupRoot, pointing at the pool.
The worker sees the original sender and responds directly to them. Each hop is a Forward, not a Send, so the chain doesn't accumulate latency through extra request/response round-trips.
What happens on failure:
A worker dies. The pool replaces it lazily on the next forward attempt to that worker. The shard never goes fully offline; capacity dips by one until the replacement is up. In-flight messages in the dead worker's mailbox are lost.
A pool dies. SupRoot's OFO supervisor restarts it. The shard name in the registry is rebound to the new pool. The router's next forward resolves to the new pool. Workers inside the pool start fresh; their state and mailboxes are gone.
The router dies. SupRoot restarts it. Senders that were mid-call see a transient failure; senders using fire-and-forget Send see no error directly (the message landed in the router's mailbox before it crashed).
This pattern doesn't preserve per-worker mailboxes across worker crashes. If you have stateful workers whose in-flight queue is critical state, fronting them with a Pool is the wrong choice; use a dedicated single-worker shard with a Supervisor configured for PreserveMailbox. That pattern is rare in practice; most sharded systems tolerate at-least-once retries from senders rather than design around mailbox preservation.
Separate write-side and read-side routing in the same router. Commands shard by aggregate ID and reach write-side workers that own the aggregate's state. Queries route by view type to dedicated projections, optionally load-balanced across replicas.
Commands and queries flow through the same actor but never through the same logic. Senders don't care which projection answers their query, and they don't have to know which shard owns their aggregate.
When the domain layout is large, split routing into tiers. The top router dispatches by domain; each domain router does content-based or hash-based routing within its area.
Each owned slot is itself a Router. Two hops, but each level deals with a much simpler routing decision.
DisableRoute and EnableRoute take a slot offline without restarting the router. Useful for planned maintenance or as a manual circuit breaker when a downstream is misbehaving.
For automatic circuit breaking, watch for MessageRouteFailed in HandleMessage, call DisableRoute after a failure threshold, and re-enable after a cooldown or a probe sent through the admin path.
Swap a worker's factory at runtime to deploy new code without restarting the router:
If strict draining is required, combine with DisableRoute, wait, ReplaceRoute, then EnableRoute.
Start with services registered as standalone supervised processes outside the router. The router routes to them through the registry fallback. Later, promote a high-traffic service into a router-owned slot without changing senders or RouteMessage:
RouteMessage returns "payments" in both cases. The router's resolution silently transitions from registry lookup to owned-slot forwarding. Existing senders see no change.
Use a router when:
Routing decisions depend on message content (event type, sender, hash of a key).
You need key affinity (same key always to the same worker) for stateful workloads.
You want named routes that survive worker restarts so other parts of the system can reason about them.
You want a single entry point that dispatches to processes owned elsewhere.
Don't use a router when:
You need pure round-robin distribution across identical workers. act.Pool is simpler and faster.
You need supervision policy (intensity limits, restart strategies, mailbox preservation). Supervise the worker externally and let the router resolve its registered name through the registry; don't put act.Supervisor in a router slot.
The senders can address workers directly by registered name and you don't need a dispatcher between them.
Router and Pool are complementary, not competing. A router that needs capacity per shard routes to a pool registered under each shard's name; the pool and the router live as siblings under a common supervisor. A pool that needs content-based dispatch uses a router in front of it. The two primitives compose by name through the registry, not by nesting.
Stable indices for sharding. When routing by hash, derive the name from a fixed table of N route names. Adding or removing routes at runtime changes N and breaks affinity. If you need elastic shard counts, you need an explicit reshard protocol; the router itself doesn't provide one.
Owned vs registry routes look the same to RouteMessage. The callback just returns a name. The router decides whether it's owned or external. This decoupling lets you migrate a route in or out of router ownership without touching the routing callback. Use Route(name) to check ownership when you need to.
MessageRouteFailed is the only feedback for async failures. If HandleMessage ignores it, async messages routed to nonexistent names disappear silently. At minimum log them; better, persist to a dead-letter queue so they can be replayed.
Pending operations are observable. DisableRoute returns immediately, but the worker is still alive for a brief window. If you call Route(name) right after, you see Pending: RoutePendingDisable, not Disabled: true. Wait for the transition or check Pending explicitly when sequencing operations.
Routers do not preserve mailboxes across restarts. If a worker dies with messages in its mailbox, those messages are lost. For stateful workers that must retain in-flight messages, supervise them externally with PreserveMailbox: true on the child spec and let the router route to them by registered name; don't put the supervisor in a router slot.
Remote routing is not in scope. RouteMessage returns gen.Atom, which addresses local names. To forward to a remote node, do it explicitly from HandleMessage on the admin path (sender uses high priority) or send directly from wherever knows the remote topology. Keeping remote out of the router avoids dragging network failure modes, retries, and important-delivery decisions into the routing primitive.
Pending operations are exclusive per route. While a route is mid-disable, you cannot start a replace or a remove on it. Each pending operation is a short window (one mailbox round-trip); retry after the previous one resolves, or check Route(name).Pending first. Concurrent pending operations on different routes are fine; the lock is per route, not router-wide.
Handling synchronous requests in the asynchronous actor model
The actor model is fundamentally asynchronous. Processes send messages and continue immediately without waiting for responses. This asynchrony is core to the model - actors don't block, they process messages one at a time from their mailbox, and they scale because thousands of actors can run concurrently without threads blocking on I/O or responses.
But real systems often need synchronous patterns. A client makes a request and must wait for a response before continuing. An HTTP handler receives a request and can't return to the client until the response is ready. A database query needs to block until the data arrives. These synchronous requirements don't disappear just because your system uses actors.
The challenge is satisfying these synchronous requirements without actually blocking the actor. If an actor blocks waiting for a response, it can't process other messages in its mailbox. The actor becomes unresponsive to everything else. This defeats the purpose of the actor model - you want concurrent message processing, not sequential blocking.
This chapter explores how to handle synchronous-style requests while maintaining asynchronous actor behavior. You'll learn how the framework implements request-response, how to handle Call requests efficiently, and how to process them asynchronously even when the caller is blocked waiting.
In traditional synchronous code, when you call a function, you wait for it to return:
result := database.Query("SELECT * FROM users")
// blocked here until query completes
processResult(result)The calling thread stops. The operating system schedules other threads. Eventually the query completes, the thread wakes up, and execution continues. This is fine when you have many threads - some block, others run. But it's wasteful, and it doesn't scale to tens of thousands of concurrent operations.
In the actor model, you send a message and continue:
The sender doesn't block. The message goes into the database actor's mailbox. When the database actor processes it, it sends a response message back. The original sender handles that response later in its own message loop. This is how actors achieve massive concurrency - no actor ever blocks waiting, so you can run thousands of actors with a small thread pool.
But what if the sender legitimately needs to wait? What if it's an HTTP handler that can't return to the client until the query completes?
The framework provides Call for this:
From the caller's perspective, this looks synchronous - you call, you wait, you get a result. But from the system's perspective, it's asynchronous:
The caller sends a request message with a unique reference (gen.Ref)
The caller's goroutine blocks waiting for a response with that reference
The recipient receives the request as a HandleCall invocation
The caller blocks, but blocking is isolated to that one actor. The actor's goroutine is suspended (cheap), not spinning (expensive). Other actors run normally. The recipient processes the request whenever it gets to it in its mailbox, not immediately. The entire system remains asynchronous, but individual actors can use synchronous-style APIs when needed.
When a process receives a Call request, the framework invokes HandleCall:
Critical distinction: The error you return from HandleCall is not the response to the caller - it's the termination reason for your process!
return result, nil - Send result to caller, continue running
return errorValue, nil - Send errorValue to caller, continue running
return result, gen.TerminateReasonNormal
When you return a non-nil result from HandleCall, the framework automatically sends it as a response message to the caller. The caller's blocked Call unblocks and returns your result. Any value can be a result - integers, strings, structs, even errors.
If you need to send an error to the caller, return the error as the result value, not as the error return:
The second return value (error) is for terminating your process. Return gen.TerminateReasonNormal to gracefully stop, or any other error for abnormal termination. If you return both a result and gen.TerminateReasonNormal, the framework sends the result first, then terminates your process.
From the caller's side:
The caller blocks at Call until your HandleCall returns. This can be milliseconds (local, fast computation) or seconds (remote, slow operation). The caller can specify a timeout - if no response arrives within the timeout, Call returns nil, gen.ErrTimeout.
Note the distinction: err from Call is a framework-level error (timeout, network failure, process terminated). The result itself might be an error value sent by your HandleCall - that's application-level.
You might wonder: why not just use Go channels for request-response?
This breaks the actor model in subtle ways:
Shared memory - Channels are shared memory. Passing a channel in a message creates a direct communication path outside the actor system. If the worker is on a remote node, the channel doesn't work (channels don't serialize). Your code becomes non-portable between local and remote.
Blocking semantics - Blocking on a channel blocks the actor's goroutine, but the actor is still "running" from the framework's perspective. The actor can't process other messages while blocked. With Call, the framework knows the actor is waiting for a response and can properly account for it (the actor is in ProcessStateWaitResponse).
Timeout coordination - Channels don't have built-in timeouts. You'd wrap them in select with time.After, but timeout cleanup is tricky. With Call, timeouts are built-in, and references have deadlines that the receiver can check.
No network transparency - Call works identically for local and remote processes. Channels don't. If you use channels for local request-response, your code won't work when you move to a distributed deployment.
The framework's Call mechanism is designed specifically for request-response in the actor model, works across the network, and integrates properly with the actor lifecycle.
A common pattern is a server process that receives many Call requests. If processing each request takes time (database query, HTTP call, complex computation), handling them sequentially in HandleCall creates a bottleneck. One slow request delays all subsequent requests.
The solution is act.Pool - a specialized actor that automatically distributes requests across a pool of worker actors:
Notice what's not in this code - there's no HandleCall for the Server. You don't need one.
act.Pool automatically intercepts all incoming Call requests and forwards them to workers. When you send a Call to the Server PID, the Pool:
Receives the Call request in its mailbox
Pops an available worker from the pool
Forwards the entire request (from, ref, message) to the worker
Returns the worker to the pool (reusable for next request)
The worker receives the Call request with the original caller's PID and ref. When the worker returns a result from HandleCall, it goes directly to the original caller, bypassing the Pool entirely. The Pool is just a router.
From the caller's perspective:
This gives you concurrent request processing:
10 Call requests arrive at the Server simultaneously
Pool forwards each to a different worker
All 10 workers process concurrently
Each worker responds directly to its caller
The caller's experience is unchanged - they call, they block, they get a result. They don't know about the pool. The concurrency is entirely internal to the server.
Worker resilience:
If a worker dies, the Pool replaces it - but lazily, when it next tries to forward to that worker and gets ErrProcessUnknown or ErrProcessTerminated back. Nothing notifies the Pool at the moment of death.
If every worker's mailbox is full, the request is dropped, not queued. The Pool walks its workers once, pushing each full one back, and when the walk ends it logs "no available worker process. ignored message from ...", increments ergo:messages_unhandled and releases the message. There is no Pool-side buffer and no backpressure to the sender: a Call in this situation ends in the caller's timeout, and a Send disappears silently.
ProcessOptions.Fallback does not catch these. PoolOptions offers no way to give workers a fallback, and the Pool delivers with Forward, which bypasses the fallback path entirely - see . Watch the counter through the inspect callback, and size PoolSize and WorkerMailboxSize for the peak rather than the average.
For more details on Pool configuration and advanced patterns, see .
Sometimes you need to handle a Call request asynchronously within a single actor, without workers. Maybe you're waiting for a timer, or you need to make another Call before you can respond, or you want to batch multiple requests.
You can do this manually:
The pattern:
HandleCall stores from and ref for later
HandleCall returns (nil, nil) - async handling
Later (timer, another message, whatever), you process the request
You must respond eventually, or the caller will timeout. If you lose track of the ref or forget to respond, the caller waits until timeout and gets gen.ErrTimeout.
The result you send with SendResponse can be any value - strings, numbers, structs, even errors. If you want to send an error to the caller, just send it as a normal result value:
The caller receives it as result (first return value from Call) and can check if it's an error.
When you handle Call requests asynchronously, you send responses later using SendResponse. But there's also SendResponseError. What's the difference, and when do you use each?
The difference is in which return value the caller receives from Call.
SendResponse sends to the result channel:
Whatever you send appears as the first return value (result). The second return value (err) is nil, meaning no framework error occurred. The result can be anything - strings, numbers, structs, even errors:
The caller must check if the result is an error:
SendResponseError sends to the error channel:
The error appears as the second return value (err), exactly where framework errors like timeout and network failures appear. The first return value (result) is nil.
From the caller's perspective, there's no difference between an error from SendResponseError and a framework error:
The problem with mixing channels
The framework uses the error channel for transport errors - problems with the messaging infrastructure. Your application uses it for business logic results. When you call SendResponseError, you're mixing these two concerns.
Consider a typical caller error handling:
This makes sense for transport errors - network glitches, temporary overload. But if the database actor uses SendResponseError for "record not found", the caller retries unnecessarily. The record won't appear in one second.
The caller has no way to distinguish. Both arrive through the error channel.
When mixing is justified
Despite this issue, SendResponseError has legitimate uses. The key is: use it for errors that should be handled like transport errors.
Imagine a database query actor. It receives queries, executes them against a database, and returns results. What errors can occur?
Application errors - problems with the query itself:
Bad SQL syntax
Permission denied
Constraint violation
These are not infrastructure problems. The actor is working fine, the database is up, the request was processed. The query just has issues. The caller should see these as results, not transport failures.
Infrastructure errors - problems with the database connection:
Database server is down
Network to database lost
Connection pool exhausted
Too many simultaneous connections
These are infrastructure problems. The actor couldn't process the request because a dependency is unavailable. From the caller's perspective, this is the same as if the actor itself were unreachable (timeout) or the node were down (network failure). The caller should handle all of these identically - retry, fallback, circuit breaking.
Here's how to implement this:
The caller handles both channels naturally:
This works because the caller wants to handle infrastructure failures identically, regardless of whether they originate from the framework (timeout, network) or from the application (database down). Both represent unavailable service, both trigger the same fallback logic.
Guideline
Use SendResponse for all normal cases, including expected errors (validation, not found, unauthorized). These are results - the request was processed, here's what happened.
Use SendResponseError only when the error represents an infrastructure failure that the caller should treat the same as transport errors - retry with backoff, circuit breaking, fallback to alternative services.
If in doubt, use SendResponse. It keeps transport and application concerns separate, giving the caller maximum clarity.
When you handle requests asynchronously, the caller might timeout before you respond. Imagine:
Caller makes a Call with 5 second timeout
Your HandleCall stores the request, returns nil (async)
6 seconds pass
Caller's timeout fires, Call returns gen.ErrTimeout
Your response arrives after the caller stopped waiting. The caller won't receive it (it's not waiting on that ref anymore). Your work was wasted.
You can detect this with ref.IsAlive():
ref.IsAlive() checks the deadline embedded in the reference. When the caller made the Call with a timeout, the framework created a reference with MakeRefWithDeadline(now + timeout). The deadline is stored in ref.ID[2] as a unix timestamp. IsAlive() compares it to the current time - if the deadline passed, it returns false.
This lets you skip processing expired requests. If a request took too long to reach the front of the queue, and the caller already gave up, don't waste resources computing a response nobody will receive.
But be careful: IsAlive() returning false doesn't mean the caller is definitely gone. It means the deadline passed. The caller might have disappeared for other reasons (crash, network disconnect), or they might still exist but already moved on. It's a hint for optimization, not a guarantee about caller state.
If you send a response after the deadline, nothing bad happens. The response message arrives, the receiver checks if anyone is waiting for that ref, finds nobody, and drops the message. It's just wasted work - harmless but inefficient.
Pattern: Immediate vs deferred
Some requests you can answer immediately, others need async processing. Mix both in the same HandleCall based on the situation.
Pattern: Batch processing
Accumulate requests, process them together, respond to each individually. Efficient for operations with high setup cost (database connections, API requests with rate limits).
Pitfall: Losing references
You need both from and ref to send a response. Store them together.
Pitfall: Confusing result errors with termination errors
This is the most common mistake. Remember: the error return from HandleCall terminates your process, it doesn't go to the caller (except the special case of gen.TerminateReasonNormal with a non-nil result).
Pitfall: Blocking in HandleCall
Even though the caller is blocked waiting, your actor shouldn't block. If you sleep for 5 seconds, you can't handle other messages during that time. Other callers will queue up waiting. If this is unavoidable (calling a blocking API you don't control), spawn a worker to handle it or use act.Pool.
Everything discussed so far assumes the response message arrives. But what if it doesn't? Networks drop packets. Remote processes crash. Connections fail.
When a response is lost, the caller blocks until timeout. Eventually Call returns gen.ErrTimeout, but you don't know if the request was processed or not. Did the receiver handle it and the response got lost? Or did the request itself get lost before reaching the receiver?
This uncertainty is a fundamental problem in distributed systems. The framework's Call mechanism gives you request-response semantics, but it doesn't guarantee the response arrives. It's "best effort" - works reliably for local calls and stable network connections, but no guarantees.
For many use cases, this is fine. Timeouts are acceptable. Callers can retry. Idempotent operations tolerate retries. But some operations can't tolerate uncertainty. A payment authorization must definitely succeed or definitely fail - timeout isn't acceptable.
The solution is Important Delivery. When you enable the Important flag, the framework changes from "best effort" to "confirmed delivery." Responses don't just get sent, they get acknowledged. If the response fails to deliver, you know immediately rather than waiting for timeout.
Important Delivery makes the network transparent for failures, not just successes. It turns request-response from "probably works" into "definitely works or definitely fails, no ambiguity."
We'll explore Important Delivery in depth in the next chapter. For now, understand that everything you've learned about Call and HandleCall still applies. Important Delivery is a layer on top, not a replacement. You'll still handle requests the same way - the framework just makes delivery more reliable.
For details on how messages and calls flow through the network, see . For understanding delivery guarantees, continue to .
HandleMessagegen.ErrNoRoute: the target route is in the process of being removed.
gen.ErrBusy: the target route is mid-replace and the old worker has already terminated; the new worker has not yet been spawned (narrow window).
The pool's own forwarding logic picks the next worker round-robin and forwards again, also preserving the original sender.
Async commands and sync queries should route differently (CQRS).
You want to operate routes at runtime: add, remove, disable, replace without restarting the dispatcher.
The response arrives in the caller's mailbox, waking up the blocked goroutine
The caller's Call returns with the result
resultreturn nil, someError - Terminate with someError, no response sent to caller
Pool remains free to route more requests
Call SendResponse(from, ref, result) to send the result
Caller's blocked Call unblocks with your result
Your actor finishes processing and calls SendResponse
type EventRouter struct {
act.Router
}
func (r *EventRouter) Init(args ...any) (act.RouterOptions, error) {
return act.RouterOptions{
Routes: []act.Route{
{Name: "payments", Factory: factory_PaymentsWorker},
{Name: "shipments", Factory: factory_ShipmentsWorker},
{Name: "reports", Factory: factory_ReportsWorker},
},
}, nil
}
func (r *EventRouter) RouteMessage(from gen.PID, msg any) gen.Atom {
switch msg.(type) {
case PaymentEvent:
return "payments"
case ShipmentEvent:
return "shipments"
case ReportEvent:
return "reports"
}
return act.RouteDiscard
}
func (r *EventRouter) RouteCall(from gen.PID, ref gen.Ref, req any) gen.Atom {
return act.RouteDiscard // no sync routing in this example
}
func factory_EventRouter() gen.ProcessBehavior {
return &EventRouter{}
}
// Spawn
routerPID, err := node.Spawn(factory_EventRouter, gen.ProcessOptions{})// Sender
process.Send(routerPID, PaymentEvent{...})
// Worker
func (w *PaymentsWorker) HandleMessage(from gen.PID, msg any) error {
// 'from' is the original sender, not routerPID
w.Send(from, PaymentReceipt{...})
return nil
}func (r *OrdersRouter) RouteMessage(from gen.PID, msg any) gen.Atom {
cmd, ok := msg.(OrderCommand)
if ok == false {
return act.RouteDiscard
}
return r.writeShards[cmd.OrderID()%uint64(len(r.writeShards))]
}
func (r *OrdersRouter) RouteCall(from gen.PID, ref gen.Ref, req any) gen.Atom {
switch req.(type) {
case OrderByIDQuery: return "orders.view"
case OrdersByUserQuery: return "user_orders.view"
case OrderStatsQuery: return "stats.view"
}
return act.RouteDiscard
}const (
RoutePendingNone RoutePending = 0
RoutePendingDisable RoutePending = 1
RoutePendingReplace RoutePending = 2
RoutePendingRemove RoutePending = 3
)func (r *Router) Routes() []RouterRouteInfo
func (r *Router) Route(name gen.Atom) (RouterRouteInfo, bool)
func (r *Router) AddRoute(route Route) error
func (r *Router) RemoveRoute(name gen.Atom) error
func (r *Router) DisableRoute(name gen.Atom) error
func (r *Router) EnableRoute(name gen.Atom) error
func (r *Router) ReplaceRoute(name gen.Atom, route Route) error
func (r *Router) RespawnRoute(name gen.Atom) error// Routed to a worker
process.Send(routerPID, PaymentEvent{...})
// Handled by the router
process.SendWithPriority(routerPID, AddShardCommand{...}, gen.MessagePriorityHigh)func (r *EventRouter) HandleMessage(from gen.PID, msg any) error {
switch m := msg.(type) {
case act.MessageRouteFailed:
if errors.Is(m.Reason, gen.ErrDisabled) {
r.persistToDLQ(m.Name, m.Message)
return nil
}
r.Log().Warning("route %s failed: %s", m.Name, m.Reason)
case AddShardCommand:
if err := r.AddRoute(act.Route{Name: m.Name, Factory: m.Factory}); err != nil {
r.Log().Error("add shard: %s", err)
}
}
return nil
}type MessageRouteFailed struct {
Name gen.Atom // target name returned by RouteMessage
From gen.PID // original sender
Message any // the message that could not be delivered
Reason error // why
}stats, err := node.Inspect(routerPID)
// stats contains:
// - "ergo:type": "Router"
// - "ergo:routes_total": N
// - "ergo:routes_active": number of routes with non-empty PID
// - "ergo:routes_disabled": count of routes with Disabled=true
// - "ergo:routes_pending": count of routes with pending != None
// - "ergo:mailbox_size": router's MailboxSize from Init
// - "ergo:forwarded": total successful forwards
// - "ergo:discarded": total RouteDiscard returns
// - "ergo:failed": total forward failures (including registry misses)
// - "ergo:restarts": total worker restarts
// - "ergo:route:NAME:pid": pid of the route (empty string if not running)
// - "ergo:route:NAME:disabled": "true" or "false"
// - "ergo:route:NAME:pending": "disable" / "replace" / "remove" if pendingtype EventRouter struct {
act.Router
}
func (r *EventRouter) Init(args ...any) (act.RouterOptions, error) {
return act.RouterOptions{
Routes: []act.Route{
{Name: "payments", Factory: factory_PaymentsWorker},
{Name: "shipments", Factory: factory_ShipmentsWorker},
{Name: "reports", Factory: factory_ReportsWorker},
},
}, nil
}
func (r *EventRouter) RouteMessage(from gen.PID, msg any) gen.Atom {
switch msg.(type) {
case PaymentEvent:
return "payments"
case ShipmentEvent:
return "shipments"
case ReportRequest:
return "reports"
}
return act.RouteDiscard
}const ShardCount = 16
type ShardRouter struct {
act.Router
shards []gen.Atom
}
type ShardedMessage interface {
ShardKey() uint64
}
func (r *ShardRouter) Init(args ...any) (act.RouterOptions, error) {
routes := make([]act.Route, ShardCount)
r.shards = make([]gen.Atom, ShardCount)
for i := range routes {
name := gen.Atom(fmt.Sprintf("shard:%d", i))
r.shards[i] = name
routes[i] = act.Route{Name: name, Factory: factory_ShardWorker, Args: []any{name}}
}
return act.RouterOptions{Routes: routes}, nil
}
func (r *ShardRouter) RouteMessage(from gen.PID, msg any) gen.Atom {
keyed, ok := msg.(ShardedMessage)
if ok == false {
return act.RouteDiscard
}
return r.shards[keyed.ShardKey()%uint64(len(r.shards))]
}
func (r *ShardRouter) RouteCall(from gen.PID, ref gen.Ref, req any) gen.Atom {
return act.RouteDiscard
}const ShardCount = 16
// SupRoot supervises N pools (each registered under its shard name)
// and the router itself. One supervisor for the whole shard layer.
type SupRoot struct {
act.Supervisor
}
func (s *SupRoot) Init(args ...any) (act.SupervisorSpec, error) {
children := make([]act.SupervisorChildSpec, 0, ShardCount+1)
for i := 0; i < ShardCount; i++ {
children = append(children, act.SupervisorChildSpec{
Name: gen.Atom(fmt.Sprintf("shard:%d", i)),
Factory: factory_ShardPool,
})
}
children = append(children, act.SupervisorChildSpec{
Name: "shard_router",
Factory: factory_ShardRouter,
})
return act.SupervisorSpec{
Type: act.SupervisorTypeOneForOne,
Children: children,
Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyTransient,
},
}, nil
}
// Each shard is a Pool of workers. The Pool itself handles worker
// distribution and lazy restart inside the shard.
type ShardPool struct {
act.Pool
}
func (p *ShardPool) Init(args ...any) (act.PoolOptions, error) {
return act.PoolOptions{
WorkerFactory: factory_ShardWorker,
PoolSize: 4,
}, nil
}
// The router is free (no owned routes). It resolves shard names through
// the node registry, which finds the pools registered by SupRoot.
type ShardRouter struct {
act.Router
shards []gen.Atom
}
type ShardedMessage interface {
ShardKey() uint64
}
func (r *ShardRouter) Init(args ...any) (act.RouterOptions, error) {
r.shards = make([]gen.Atom, ShardCount)
for i := range r.shards {
r.shards[i] = gen.Atom(fmt.Sprintf("shard:%d", i))
}
return act.RouterOptions{}, nil
}
func (r *ShardRouter) RouteMessage(from gen.PID, msg any) gen.Atom {
keyed, ok := msg.(ShardedMessage)
if ok == false {
return act.RouteDiscard
}
return r.shards[keyed.ShardKey()%uint64(len(r.shards))]
}
func (r *ShardRouter) RouteCall(from gen.PID, ref gen.Ref, req any) gen.Atom {
return act.RouteDiscard
}type OrdersRouter struct {
act.Router
writeShards []gen.Atom
}
func (r *OrdersRouter) Init(args ...any) (act.RouterOptions, error) {
r.writeShards = make([]gen.Atom, 16)
routes := make([]act.Route, 0, 16+3)
for i := range r.writeShards {
name := gen.Atom(fmt.Sprintf("orders.write:%d", i))
r.writeShards[i] = name
routes = append(routes, act.Route{
Name: name,
Factory: factory_OrderAggregate,
Args: []any{name},
})
}
routes = append(routes,
act.Route{Name: "orders.view", Factory: factory_OrdersView},
act.Route{Name: "user_orders.view", Factory: factory_UserOrdersView},
act.Route{Name: "stats.view", Factory: factory_StatsView},
)
return act.RouterOptions{Routes: routes}, nil
}
type OrderCommand interface {
AggregateID() uint64
}
func (r *OrdersRouter) RouteMessage(from gen.PID, msg any) gen.Atom {
cmd, ok := msg.(OrderCommand)
if ok == false {
return act.RouteDiscard
}
return r.writeShards[cmd.AggregateID()%uint64(len(r.writeShards))]
}
func (r *OrdersRouter) RouteCall(from gen.PID, ref gen.Ref, req any) gen.Atom {
switch req.(type) {
case OrderByIDQuery:
return "orders.view"
case OrdersByUserQuery:
return "user_orders.view"
case OrderStatsQuery:
return "stats.view"
}
return act.RouteDiscard
}// Take payments offline
err := router.DisableRoute("payments")
// Subsequent forwards return MessageRouteFailed{Reason: gen.ErrDisabled}.
// Bring it back
err = router.EnableRoute("payments")
// Router spawns a fresh worker from the stored spec.err := router.ReplaceRoute("payments", act.Route{
Factory: factory_PaymentsWorkerV2,
})
// Current worker terminates, new spec is installed, new worker spawns.
// Messages in flight continue to the old worker until it dies;
// new messages reach the new worker.// Before: free router, "payments" lives elsewhere
return act.RouterOptions{}, nil
// After: payments is owned, others stay external
return act.RouterOptions{
Routes: []act.Route{
{Name: "payments", Factory: factory_PaymentsWorker},
},
}, nildatabase.Send(QueryRequest{SQL: "SELECT * FROM users"})
// immediately continues, doesn't wait
doOtherWork()result, err := process.Call(databasePID, QueryRequest{SQL: "SELECT * FROM users"})
// blocked here, but only this actor is blocked
// other actors continue running normallytype Calculator struct {
act.Actor
}
func (c *Calculator) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
switch req := request.(type) {
case AddRequest:
result := req.A + req.B
return result, nil
case DivideRequest:
if req.B == 0 {
// Return error as the result value, not as termination reason
return fmt.Errorf("division by zero"), nil
}
result := req.A / req.B
return result, nil
default:
// Return error as the result value
return fmt.Errorf("unknown request type"), nil
}
}// WRONG - terminates the process!
if invalid {
return nil, fmt.Errorf("invalid request")
}
// CORRECT - sends error to caller
if invalid {
return fmt.Errorf("invalid request"), nil
}// Somewhere in another actor
result, err := process.Call(calculatorPID, AddRequest{A: 10, B: 20})
if err != nil {
// This is a framework error (timeout, connection lost, etc)
process.Log().Error("call failed: %s", err)
return err
}
// Check if the result itself is an error (application-level error)
if errResult, ok := result.(error); ok {
process.Log().Error("calculator returned error: %s", errResult)
return errResult
}
sum := result.(int)
process.Log().Info("10 + 20 = %d", sum)// Tempting but wrong in actor model
response := make(chan Result)
process.Send(workerPID, Request{Data: data, ResponseChan: response})
result := <-response // block waitingtype Server struct {
act.Pool
}
type Worker struct {
act.Actor
}
func (s *Server) Init(args ...any) (act.PoolOptions, error) {
return act.PoolOptions{
PoolSize: 10, // 10 worker actors
WorkerFactory: func() gen.ProcessBehavior { return &Worker{} },
}, nil
}
// No HandleCall needed for Server! Pool handles forwarding automatically.
func (w *Worker) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
// Process the request
switch req := request.(type) {
case QueryRequest:
// Simulate slow operation
time.Sleep(100 * time.Millisecond)
result := fmt.Sprintf("Result for: %s", req.Query)
return result, nil
default:
// Return error as result value, not termination reason
return fmt.Errorf("unknown request"), nil
}
}// Caller doesn't know about the pool
result, err := process.Call(serverPID, QueryRequest{Query: "data"})
// Result comes from whichever worker handled ittype AsyncHandler struct {
act.Actor
pending map[gen.Ref]pendingRequest
}
type pendingRequest struct {
from gen.PID
data any
}
func (a *AsyncHandler) Init(args ...any) error {
a.pending = make(map[gen.Ref]pendingRequest)
return nil
}
func (a *AsyncHandler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
switch req := request.(type) {
case BatchRequest:
// Store the request for later
a.pending[ref] = pendingRequest{from: from, data: req}
// Maybe set a timer to process after accumulating more requests
a.SendAfter(a.PID(), BatchTrigger{}, 100 * time.Millisecond)
// Return nil to handle asynchronously
return nil, nil
case ImmediateRequest:
// This one we can answer immediately
return "immediate result", nil
}
// Return error as result value
return fmt.Errorf("unknown request"), nil
}
func (a *AsyncHandler) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case BatchTrigger:
// Time to respond to all pending requests
for ref, pr := range a.pending {
result := a.processBatch(pr.data)
a.SendResponse(pr.from, ref, result)
}
a.pending = make(map[gen.Ref]pendingRequest) // clear
}
return nil
}if invalid {
a.SendResponse(pr.from, ref, fmt.Errorf("validation failed"))
}// Handler
a.SendResponse(caller, ref, "success")
// Caller receives
result, err := process.Call(handler, request)
// result = "success"
// err = nil// Handler sends an error as a result
a.SendResponse(caller, ref, fmt.Errorf("user not found"))
// Caller receives
result, err := process.Call(handler, request)
// result = error("user not found")
// err = nilresult, err := process.Call(handler, request)
if err != nil {
// Framework problem - timeout, network, process died
return fmt.Errorf("call failed: %w", err)
}
if errResult, ok := result.(error); ok {
// Application-level error
return fmt.Errorf("operation failed: %w", errResult)
}
// Success - use result
processResult(result)// Handler
a.SendResponseError(caller, ref, fmt.Errorf("database unavailable"))
// Caller receives
result, err := process.Call(handler, request)
// result = nil
// err = error("database unavailable")result, err := process.Call(handler, request)
if err != nil {
// Could be:
// - Timeout (gen.ErrTimeout)
// - Network failure (gen.ErrNoConnection)
// - Process crashed (gen.ErrProcessTerminated)
// - OR: Handler sent via SendResponseError
// Caller cannot distinguish!
return fmt.Errorf("call failed: %w", err)
}result, err := process.Call(databaseActor, query)
if err != nil {
// Retry logic for transport errors
time.Sleep(1 * time.Second)
result, err = process.Call(databaseActor, query)
if err != nil {
return err // Give up
}
}type DatabaseActor struct {
act.Actor
db *sql.DB
pending map[gen.Ref]pendingRequest
}
type pendingRequest struct {
from gen.PID
query string
}
func (d *DatabaseActor) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
query := request.(string)
// Store for async processing
d.pending[ref] = pendingRequest{from: from, query: query}
// Trigger async processing
d.Send(d.PID(), executeQuery{ref: ref})
return nil, nil // Will respond asynchronously
}
func (d *DatabaseActor) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case executeQuery:
pr := d.pending[msg.ref]
// Execute query
rows, err := d.db.Query(pr.query)
if err != nil {
// Distinguish error types
if isInfrastructureError(err) {
// Database down, connection lost, etc
// Send as transport error - caller should retry/fallback
d.SendResponseError(pr.from, msg.ref, fmt.Errorf("database unavailable: %w", err))
} else {
// Bad SQL, permission denied, etc
// Send as application result - caller should show to user
d.SendResponse(pr.from, msg.ref, fmt.Errorf("query failed: %w", err))
}
delete(d.pending, msg.ref)
return nil
}
// Success
d.SendResponse(pr.from, msg.ref, rows)
delete(d.pending, msg.ref)
}
return nil
}
func isInfrastructureError(err error) bool {
// Check for connection-related errors
if strings.Contains(err.Error(), "connection refused") {
return true
}
if strings.Contains(err.Error(), "too many connections") {
return true
}
// ... other infrastructure error checks
return false
}result, err := process.Call(databaseActor, "SELECT * FROM users")
if err != nil {
// Infrastructure problem:
// - Database is down (SendResponseError)
// - Actor timed out (gen.ErrTimeout)
// - Network failure (gen.ErrNoConnection)
// All handled the same way - try fallback
process.Log().Warning("database unavailable, using cache: %s", err)
return useFallbackCache()
}
// Check if result is an error
if errResult, ok := result.(error); ok {
// Application error - bad query, permission denied, etc
// Don't retry, don't fallback - show to user
return fmt.Errorf("query error: %w", errResult)
}
// Success
return resultfunc (a *AsyncHandler) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case BatchTrigger:
for ref, pr := range a.pending {
// Check if the caller is still waiting
if !ref.IsAlive() {
// Timeout expired, don't bother processing
a.Log().Warning("request %s expired, skipping", ref)
delete(a.pending, ref)
continue
}
// Still waiting, process and respond
result := a.processBatch(pr.data)
a.SendResponse(pr.from, ref, result)
delete(a.pending, ref)
}
}
return nil
}func (a *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
switch req := request.(type) {
case CachedRequest:
// We have the answer immediately
if result, found := a.cache[req.Key]; found {
return result, nil
}
// Cache miss, fetch asynchronously
a.pending[ref] = pendingRequest{from: from, data: req}
a.fetchFromBackend(req.Key, ref)
return nil, nil
case WriteRequest:
// Writes are fast, handle synchronously
a.data[req.Key] = req.Value
return "ok", nil
}
// Return error as result value
return fmt.Errorf("unknown request"), nil
}type Batcher struct {
act.Actor
pending []pendingRequest
timer gen.CancelFunc
}
type pendingRequest struct {
from gen.PID
ref gen.Ref
data any
}
func (b *Batcher) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
// Add to batch
b.pending = append(b.pending, pendingRequest{from, ref, request})
// Start timer if this is the first request.
// SendAfter returns (gen.CancelFunc, error).
if len(b.pending) == 1 {
cancel, err := b.SendAfter(b.PID(), Flush{}, 100*time.Millisecond)
if err != nil {
return nil, err
}
b.timer = cancel
}
// If batch is full, flush immediately
if len(b.pending) >= 100 {
if b.timer != nil {
b.timer() // cancel timer
}
b.flush()
}
return nil, nil
}
func (b *Batcher) flush() {
// Process all pending requests in one batch
results := b.processBatch(b.pending)
for i, pr := range b.pending {
if pr.ref.IsAlive() {
b.SendResponse(pr.from, pr.ref, results[i])
}
}
b.pending = b.pending[:0] // clear, keep capacity
}// WRONG: Storing only the reference
func (a *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
a.pendingRefs = append(a.pendingRefs, ref) // Lost the 'from'!
return nil, nil
}
// Later - how do we respond?
func (a *Handler) respond() {
for _, ref := range a.pendingRefs {
a.SendResponse(???, ref, result) // Who do we send to?
}
}// WRONG: This terminates your process!
func (a *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
if !a.isAuthorized(from) {
return nil, fmt.Errorf("unauthorized") // OOPS! Process terminates
}
return a.process(request), nil
}
// CORRECT: Send error as result to caller
func (a *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
if !a.isAuthorized(from) {
return fmt.Errorf("unauthorized"), nil // Caller gets error, process continues
}
return a.process(request), nil
}
// ALSO CORRECT: For async handling
func (a *Handler) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case processedResult:
// Send any result - value or error, doesn't matter
a.SendResponse(msg.caller, msg.ref, msg.result)
}
return nil
}// WRONG: Blocks the actor
func (a *Handler) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
time.Sleep(5 * time.Second) // Actor can't process other messages!
return "done", nil
}Evolving message contracts in distributed clusters
Distributed systems evolve. Services gain features, data models change, and deployments happen gradually. During a rolling upgrade, some nodes run new code while others still run the old version. A message sent from a new node must be understood by an old node, and vice versa.
EDF gives you two ways to handle this, and which one is right is a business decision, not a technical default. Strict by default: a message is its exact Go type, so changing a struct creates a new, incompatible type, and every change is explicit and caught at compile time. Or opt into schema evolution: EDF tolerates fields appended to the end of a struct, so a node that has not learned a new field keeps working. Strict typing buys deliberate, visible change control; evolution buys less coordination during rolling deploys. Which matters more is a property of your domain, not of the framework.
This article covers both - how to version messages explicitly, and when schema evolution fits instead - so your cluster handles upgrades gracefully.
The strict strategy is the default. Unlike Protobuf or Avro, EDF does not provide automatic backward compatibility here: there are no field numbers, and every registered field is encoded positionally and always present on the wire. A struct is its type. Change the struct - create a new type.
A pointer field (*int) is still optional in the value sense - nil or a value - but the field itself stays part of the type on both sides. That is not the same as a Protobuf optional field, which can be absent from the message entirely; absence-of-field is exactly what the strict default does not allow (and what schema evolution adds, only for trailing fields). The alternative is covered in below; pick whichever fits your domain.
The strict approach is straightforward: create a new type for each version.
Both types coexist in the codebase. The receiver handles whichever version arrives:
All message types must be registered with the network stack before connection establishment. The declarative form lives in ApplicationSpec.Network:
For dynamic registration (types resolved at runtime) the imperative a.Node().Network().RegisterTypes(...) from Load is also supported. For details on the type registry and the package-level edf.RegisterTypeOf API, see .
There are two ways to organize versioned types: version in the type name or version in the package path. Both work with EDF. Choose based on your team's preferences.
Important: Do not confuse package path versioning with Go modules v2+. Go modules v2+ requires changing both go.mod and all import paths when bumping major version (company.com/events/v2). This forces all consumers to update imports simultaneously, creates , and generally causes more pain than it solves. Keep your module below v2.0.0 to avoid triggering this mechanism.
All versions live in the same package:
Handler uses type names directly:
Advantages:
Single import for all versions
All versions visible in one place - evolution is clear
One registration file for all types
Simpler directory structure
Each version is a separate package:
Handler uses package aliases:
Advantages:
Clean type names without version suffix
Familiar to Protobuf users
Clear directory separation between versions
Removing a version means deleting a directory
For projects where message versions evolve in parallel, place go.mod in each domain directory:
The /v1/ and /v2/ segments are in the middle of the module path, not at the end. Go only applies v2+ import path requirements when /vN is the final path element, so company.com/messaging/v1/events is safe.
This structure allows:
V1 to continue receiving new message types while V2 is developed
Each domain to have isolated dependencies
Clean removal - deleting a directory removes the module entirely
Tagging submodules: Git tags for nested modules must include the path prefix. For module company.com/messaging/v1/events located at v1/events/, use tag v1/events/v0.1.0, not just v0.1.0.
This documentation uses version in type name for examples. The approach keeps related versions together and requires less import management. However, version in path is equally valid if your team prefers cleaner type names.
Whichever you choose, stay consistent across the codebase.
The versioning mechanism is clear. The next question: where should these types live, and who controls their evolution?
The answer depends on how the message is used. Not all messages are equal - some travel between two specific services, others broadcast across the entire cluster.
Direct communication between specific services. Request/response patterns between known parties.
Owner: receiver
Payment Service defines what it accepts. Order Service adapts to Payment's contract.
Domain events published to multiple subscribers. Any service can subscribe.
Owner: shared repository
Events represent domain facts, not service-specific contracts. Ownership belongs to a shared module that all services import.
For event publishing patterns, see .
Scope determines ownership. Who decides when to create V2? Who approves changes?
The receiver owns private contracts because it implements the logic. Multiple senders may use the same contract, but they all adapt to what the receiver accepts. This follows the pattern. Events are shared because they represent domain facts, not service-specific APIs.
Payment Service owns its API contract:
Order Service imports and uses it:
Payment team decides when to create V2. Order team adapts.
Events require broader coordination:
Breaking changes require sign-off from all consumers.
With ownership defined, the repository structure follows naturally. Private contracts live with their receivers. Cluster-wide events live in a shared module.
All message types must be registered with the network stack before connection establishment. During handshake, nodes exchange their registered type lists which become the encoding dictionaries. Registration happens from an application's Load callback, which runs after the network stack is initialized but before any traffic. Its signature is Load(args ...any) (gen.ApplicationSpec, error) - the node is not an argument, it comes from the embedded app.Application as a.Node(), which is what the example below does. There are two approaches: a centralized helper exported by the shared module, or manual registration per client.
Centralized helper exposes a single function that the consumer's application calls from Load:
Each consumer calls it from its application:
The shared events module owns the canonical list of types. Consumers register them all without having to enumerate each type, so there is no risk of forgetting one. RegisterTypes accepts a slice in any order and resolves nested-type dependencies internally.
Manual registration means each client registers only the types it uses. This gives more control but introduces risk: a missing registration is only detected at runtime, surfacing as "no encoder for type" when sending or "unknown reg type for decoding" when receiving. For most projects, centralized registration is simpler and safer. Choose based on your needs.
For message isolation patterns within a single codebase, see .
By default EDF enforces strict type identity: change a struct's field count, order, or types and it is a new, incompatible type. Schema evolution (off by default, covered below) relaxes this for the trailing fields - you may add to or remove from the end.
Field names are never on the wire - EDF encodes fields positionally - so renaming a field while keeping its type and position is wire-compatible in both modes. Make it a new version only when the rename signals a changed meaning, not a mechanical rename.
Removing a trailing field decodes cleanly by the same mechanism, but unlike appending it is a genuine deletion, not a free change. A node that still carries the field reads it as its zero value the moment an upgraded node stops sending it - and if business logic there depends on the field, it silently reads zero. That is the footgun. Remove a field only after it is deprecated and confirmed unused (see ); evolution only guarantees the removal will not break decoding mid-rollout, not that it is semantically safe.
Under the strict default, every other change requires explicit versioning. This is the opposite of Protobuf/Avro, where adding an optional field is silently compatible - and that difference is the choice you are making, not a verdict on either approach.
Consider the implicit style: you add an optional Priority field and everything "just works" - until you spend three days debugging why orders aren't prioritized correctly, because half your cluster sends the field, half ignores it, and receivers default the missing value to zero with nothing in the logs. Strict typing makes that class of bug impossible: the receiver either handles OrderV2 with its Priority, or it doesn't, and you know which at compile time.
That safety has a price - coordination. Every additive change, even a harmless new field, means a new type and a coordinated rollout. For a fast-moving service that mostly appends fields, that ceremony can cost more than the risk it removes. Schema evolution is built for exactly that case: you accept the zero-default behavior for appended fields in return for dropping the per-field versioning. Neither model is universally correct - how much you value explicit change control over deployment velocity is a business call, and EDF lets you make it per connection.
When your domain leans toward deployment velocity, schema evolution removes the per-field versioning ceremony for the append case. Enable it with the EnableSchemaEvolution network flag. Like the other capability flags, it is negotiated during handshake: evolution is active only when both ends enable it, so a connection to an older node, or one that left the flag off, stays strict.
With evolution active, you may add fields to the end of a registered struct without minting a new type. The type keeps its identity, and old and new nodes interoperate through the rolling deploy:
A node that does not know the appended field skips it (a new sender, an old receiver).
A node that expects a field an older sender did not include reads it as its zero value (an old sender, a new receiver).
During the rollout a node still running the old OrderCreated ignores Priority; an upgraded node receiving an old message sees Priority as 0. No new type, no handler branch, no anti-corruption layer.
Schema evolution covers the trailing fields, and nothing else:
Trailing fields only. The fields common to both versions must match in type and order; versions may differ only at the end. Appending is the common case; trimming the last field also works on the wire, but that is a genuine deletion with a footgun (old nodes read the dropped field as zero - see the note under the table above). Inserting, reordering, retyping, or removing a field anywhere but the end is a breaking change - create a new version.
Both nodes must enable it. A connection where either side has the flag off runs strict, so an older node never receives a form it cannot parse.
No mismatch detection. Evolution tolerates a difference in the trailing field count; it does not verify that the shared leading fields actually match. A change to those leading fields made by mistake (a reorder, a type change) is not caught - it silently misreads, exactly the zero-default class of bug the strict default prevents. The trailing-only discipline is on you.
Because of the last two points, evolution is a deliberate trade, not a free upgrade: you take on the trailing-only discipline (and its footgun) to drop per-field versioning. For anything beyond appending, or for high-stakes contracts where every change must be a visible compile-time decision, keep the strict default and version explicitly. For how EDF encodes registered types, see .
With compatibility rules clear, how do versions evolve over time?
Any change from the compatibility table above requires a new version. Additionally, create a new version when changing field semantics (same type, different meaning).
Mark deprecated versions:
Log when receiving deprecated versions:
Remove only when:
All senders upgraded to V2
Monitoring confirms zero V1 traffic
Deprecation period passed
Remove in order:
Stop accepting (return error for V1)
Remove from registration
Delete type definition
Back to the scenario from the introduction: you're deploying a new version, nodes restart one by one, and for some time the cluster runs mixed code versions. How do you handle this?
Deploy V2 types to shared module
Update receivers to handle V1 and V2
Rolling restart receiver nodes
Update senders to send V2
Receivers must support both versions during the upgrade window.
For deployment patterns with weighted routing, see .
Supporting multiple versions means your handler has multiple code paths. As versions accumulate, this becomes messy. The Anti-Corruption Layer pattern isolates version translation:
Use in handler:
Single implementation handles V2. ACL converts V1 to V2. When V1 is removed, delete the ACL function - no changes to business logic needed.
With version handling and ACL in place, how do you verify it actually works? verify compatibility:
unit.Spawn puts the actor on a mock node and hands back a *unit.Subject: SendMessage delivers into it from a sender you name, and the Should* family asserts on what it did. Reach for unit.StartNode(t, "test@localhost", gen.NodeOptions{}) first when the node name or its options matter to the test. See .
Test ACL conversion:
Run contract tests in CI before merging changes to shared modules.
For actor testing patterns, see .
Consistent naming makes code self-documenting. When you see a type name, you should immediately know: is this async or sync? Is it a request or event? What version?
Prefix with Message, suffix with version:
The prefix signals fire-and-forget semantics. When reading code, MessageXXX means no response is expected. If someone writes Call(pid, MessageOrderShippedV1{}), the mismatch is immediately visible.
Use Request/Response suffix:
Paired naming makes contracts explicit. ChargeRequest implies ChargeResponse exists. The caller knows to expect a result.
Domain events use past tense without prefix:
Events describe facts that already happened, not requests for action. Past tense (Created, Received) distinguishes them from commands (Create, Charge).
If using version in type name strategy, always suffix with version number:
If using version in path strategy, the package path carries the version and type names stay clean.
These patterns emerge repeatedly in production systems. Avoid them:
Changing existing type instead of creating new version
This is a mistake only under the strict default. With enabled on both nodes, appending Priority at the end keeps the same type and stays compatible - that is the whole point of opting in. Inserting, reordering, retyping, or removing a non-trailing field is still a breaking change either way.
Forgetting to register new types
Long coexistence periods
Supporting V1 for months creates maintenance burden. Set clear deprecation deadlines and enforce them.
Registering after connection established
Types must be registered before connections are formed. Dynamic registration requires connection cycling.
Message versioning in EDF is explicit by default: no hidden compatibility rules, no runtime surprises. When your domain favors deployment velocity over that control, schema evolution opts into append-compatibility per connection. Both are valid - the choice is a business one.
Key principles:
Choose strict versioning or schema evolution by what your business needs, not by default
Version in type name or package path, never in Go module path
Receiver owns private contracts
Shared repository for domain events
Debugging distributed actor systems presents unique challenges. Traditional debugging tools struggle with concurrent message passing, process isolation, and distributed state. This article covers the debugging capabilities built into Ergo Framework and demonstrates practical techniques for troubleshooting common issues.
Ergo Framework uses Go build tags to enable debugging features without affecting production performance. These tags control compile-time behavior, ensuring zero overhead when disabled.
pprof TagThe pprof tag enables the built-in profiler and goroutine labeling:
go run --tags pprof ./cmdThis activates:
pprof HTTP endpoint at http://localhost:9009/debug/pprof/
PID labels on actor goroutines and Alias labels on meta process goroutines for identification in profiler output
The endpoint address can be customized via environment variables:
PPROF_HOST - host to bind (default: localhost)
PPROF_PORT - port to listen on (default: 9009)
The profiler endpoint exposes standard Go profiling data:
By default, Ergo Framework recovers from panics in actor callbacks to prevent a single misbehaving actor from crashing the entire node. While this improves resilience in production, it can hide bugs during development.
With norecover, panics propagate normally, providing full stack traces and allowing debuggers to catch the exact failure point. This is particularly useful when:
Investigating nil pointer dereferences in message handlers
Tracking down type assertion failures
Understanding the call sequence leading to a panic
The verbose tag enables verbose logging of framework internals:
This produces detailed output about:
Process lifecycle events (spawn, terminate, state changes)
Message routing decisions
Network connection establishment and teardown
Supervision tree operations
To see trace output, also set the node's log level:
The latency tag enables mailbox latency measurement for all processes:
This activates:
Monotonic timestamp on every message pushed into the MPSC queue
QueueMPSC.Latency() returns the age (in nanoseconds) of the oldest unprocessed message in the queue
ProcessMailbox.Latency() returns the maximum latency across all four mailbox queues (Main, System, Urgent, Log)
Without the tag, Latency() returns -1 (disabled) and there is zero runtime overhead: no timestamps are recorded, no atomic operations are added to the message path.
The overhead with the tag enabled is approximately 10-25% on micro-benchmarks (LOCAL 1-1 scenario with a single producer and consumer exchanging messages). In real applications with many processes, the overhead is lower because the cost is amortized across concurrent operations.
Latency measurement answers the question "how long has the oldest message been sitting in this process's mailbox?" A high value means the process is not keeping up with incoming messages: it is either overloaded, stuck in a long-running callback, or blocked. This is particularly useful for:
Identifying backpressure in actor pipelines
Detecting stuck processes before they cause cascading failures
Finding hotspot processes in large clusters
For cluster-wide observability with Prometheus and Grafana, see the which integrates latency data into distribution, top-N, and per-node panels when built with the latency tag.
The typestats tag enables per-type encode/decode statistics:
This activates:
Encoded/Decoded counts per registered EDF type for root-level operations (calls at the message boundary, not nested fields)
EncodedBytes/DecodedBytes measured as decompressed wire size, pre-compression on encode and post-decompression on decode, including the type-prefix header
Stats.Enabled flag in gen.RegisteredTypeInfo set to true
Without the tag, counters remain zero, Stats.Enabled is false, and there is zero runtime overhead. Encode and decode go through pass-through wrappers that the Go inliner reduces to direct calls.
The overhead with the tag enabled is approximately 2-3% on encode/decode throughput, from two atomic.AddInt64 operations per root call.
A counter increments only when a value of that type is the message itself, the top of an Encode or Decode call. Built-in primitives like gen.PID, gen.Atom, gen.Ref typically appear as fields inside other messages, so their bytes contribute to the parent message's byte total, not to their own counters. Encoded and Decoded are independent: a node may receive some types only and send others only.
Use case: identify message types that dominate network traffic. The average byte size per operation (EncodedBytes / Encoded) indicates whether a type is a candidate for compression at the producer process. Types with a high average are strong candidates for compressing at the source; types with a low average are not worth the framing overhead.
Tags can be combined for comprehensive debugging:
or with latency measurement:
or with type statistics:
This enables all specified features simultaneously. Use combinations when investigating complex issues that span multiple subsystems.
The Go profiler is a powerful tool for understanding runtime behavior. Ergo Framework enhances its usefulness by labeling goroutines with their identifiers.
When built with the pprof tag, each actor's goroutine carries a label containing its PID, and each meta process goroutine carries a label with its Alias. This creates a direct link between the logical identity and the runtime goroutine.
To find labeled goroutines:
Example output for actors:
Example output for meta processes:
Meta processes have two goroutines with different roles:
"role":"reader" - External Reader goroutine running the Start() method (blocking I/O)
"role":"handler" - Actor Handler goroutine processing messages (HandleMessage/HandleCall)
The output shows:
The goroutine's stack trace
The identifier label (PID for actors, Alias for meta processes)
The exact location in your code where the goroutine is currently executing
The ?debug=1 profile above groups goroutines by stack and prints the labels as # labels: lines. A plain dump - ?debug=2, runtime.Stack, or the traceback of an unrecovered panic - is a different format, and until Go 1.27 it carried no labels at all.
Since Go 1.27 the labels are printed in the header line of every goroutine, after the state:
The runtime gates this on the tracebacklabels setting, whose default follows the go version your module declares: a module on go 1.21 gets the pre-1.27 behaviour and no labels. Ask for them either with a directive in the main package:
or with GODEBUG=tracebacklabels=1 in the environment. The build tag is still required - it is what attaches the labels in the first place; the setting only decides whether a plain dump prints them.
This is what makes the next section work: a dump taken with ?debug=2 can be searched by PID only when the labels are in it.
During graceful shutdown, Ergo Framework logs processes that are taking too long to terminate. These logs include PIDs that can be matched against profiler output.
Consider a shutdown scenario where the node reports:
A ticker repeats that snapshot every five seconds while the node waits, at most ten processes per snapshot plus an ...and N more line. The parenthesis holds the registered name and behavior when the process has a name, and the behavior alone when it does not.
To investigate why <ABC123.0.1005> is stuck:
Capture the goroutine profile:
Search for the specific PID:
Analyze the stack trace to understand what the actor is waiting on.
The debug=2 parameter provides full stack traces with argument values, which is more verbose than debug=1 but contains more diagnostic information.
Different types of blocking have characteristic stack traces:
Blocked on channel receive:
Blocked on mutex:
Blocked on network I/O:
Blocked on synchronous call (waiting for response):
Understanding these patterns helps quickly identify the root cause of stuck processes.
Ergo Framework provides built-in diagnostics during graceful shutdown. When ShutdownTimeout is configured (default: 3 minutes), the framework logs pending processes every 5 seconds.
The shutdown log includes:
PID: Process identifier for correlation with profiler
State: Current process state (running, sleep, etc.)
Queue: Number of messages waiting in the mailbox
A process with state=running and queue=0 is actively processing something (likely stuck in a callback). A process with state=running and queue>0 is stuck while new messages continue to arrive. A process with state=sleep and queue=0 is idle - during shutdown this typically means the process is waiting for its children to terminate first (normal supervision tree behavior).
Symptoms:
Process stops responding to messages
Other processes waiting on Call timeout
Shutdown hangs on specific process
Investigation:
Note the PID from shutdown logs or observer
Capture goroutine profile with debug=2
Find the goroutine by PID label
Examine the stack trace
Common causes:
Infinite loop in message handler
Blocking channel operation
Deadlock with another process via synchronous calls
External service call without timeout
Solution approach:
Never use blocking operations (channels, mutexes) in actor callbacks
Always use timeouts for external calls
Use asynchronous messaging patterns where possible
Symptoms:
Heap size increases over time
Process eventually killed by OOM
Investigation:
Capture heap profile:
In pprof, use top to see largest allocators:
Use list to examine specific functions:
Common causes:
Messages accumulating in mailbox faster than processing
Actor state holding references to large data
Unbounded caches or buffers in actor state
Symptoms:
Two or more processes stop responding
Circular dependency in synchronous calls
Investigation:
Identify stuck processes from shutdown logs
For each process, capture its goroutine stack
Look for waitResponse in stack traces (indicates waiting for synchronous call response)
Map the call targets to build a dependency graph
Prevention:
Prefer asynchronous messaging over synchronous calls
Design clear hierarchies where calls flow in one direction
Use timeouts on all synchronous operations
Consider using request-response patterns with explicit message types
Symptoms:
Process terminates unexpectedly
TerminateReasonPanic in logs
Investigation:
Build with --tags norecover to get full panic stack
Run the scenario that triggers the crash
Examine the complete stack trace
With norecover, the panic propagates with full context:
This shows exactly which line in your code triggered the panic.
In production, norecover is not appropriate: the framework's panic recovery is what keeps the node running after a faulty callback. To surface the same panic origin without crashing the node, register the - it captures every recovered panic with its origin stack trace and forwards it to a centralized issue tracker. Recurring panics group by root cause automatically.
The application embeds into a node and provides a web interface for inspecting it and the rest of the cluster. While not strictly a debugging tool, it complements profiler-based debugging by providing:
Real-time process list with state and mailbox sizes
Application and supervision tree visualization
Network topology view
Message inspection capabilities
Observer runs at http://localhost:9911 by default when included in your node.
Always use build tags in development: Run with --tags pprof during development to have profiler and goroutine labels available when needed.
Configure reasonable shutdown timeout: A shorter timeout (30-60 seconds) in development helps identify stuck processes quickly.
Use framework logging: The framework's Log() method automatically includes PID/Alias in log output, enabling correlation with profiler data.
Debugging actor systems requires tools that bridge the gap between logical actors and runtime goroutines. Ergo Framework provides this bridge through:
Build tags that enable profiling, diagnostics, and latency measurement without production overhead
Goroutine labels that link runtime goroutines to their actor (PID) and meta process (Alias) identities
Shutdown diagnostics that identify processes preventing clean termination
Observer integration for visual inspection of running systems
Combined with Go's standard profiling tools, these capabilities enable effective debugging of even complex distributed systems.
Size cap per struct. With evolution on, a single encoded struct is bounded at just under 4GB. The strict default has no such per-struct cap.
Rolling restart sender nodes
Deprecate V1 after all nodes upgraded
Remove V1 after deprecation period
Test version compatibility
Set deprecation deadlines
Use ACL to isolate version translation
With schema evolution, keep changes append-only - the discipline is not enforced
// Version 1
type OrderCreatedV1 struct {
OrderID int64
}
// Version 2 - new field
type OrderCreatedV2 struct {
OrderID int64
Priority int
}func (a *Actor) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case OrderCreatedV1:
return a.handleOrderV1(m)
case OrderCreatedV2:
return a.handleOrderV2(m)
}
return nil
}func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "myapp",
Network: gen.ApplicationNetwork{
RegisterTypes: []any{
OrderCreatedV1{},
OrderCreatedV2{},
},
},
Group: []gen.ApplicationMemberSpec{ /* ... */ },
}, nil
}events/
├── order_created_v1.go
├── order_created_v2.go
└── register.goimport "company.com/events"
events.OrderCreatedV1{}
events.OrderCreatedV2{}switch m := message.(type) {
case events.OrderCreatedV1:
// ...
case events.OrderCreatedV2:
// ...
}messaging/
├── v1/
│ └── events/
│ └── order_created.go
└── v2/
└── events/
└── order_created.goimport eventsv1 "company.com/messaging/v1/events"
import eventsv2 "company.com/messaging/v2/events"
eventsv1.OrderCreated{}
eventsv2.OrderCreated{}switch m := message.(type) {
case eventsv1.OrderCreated:
// ...
case eventsv2.OrderCreated:
// ...
}messaging/
├── v1/
│ ├── events/
│ │ ├── go.mod # module company.com/messaging/v1/events
│ │ └── order_created.go
│ └── payment/
│ ├── go.mod # module company.com/messaging/v1/payment
│ └── charge.go
└── v2/
├── events/
│ ├── go.mod # module company.com/messaging/v2/events
│ └── order_created.go
└── payment/
├── go.mod # module company.com/messaging/v2/payment
└── charge.goPrivate messages
Receiver
receiver-api/
Receiver team
// payment-api/charge_v1.go
package paymentapi
type ChargeRequestV1 struct {
OrderID int64
Amount int64
}
type ChargeResponseV1 struct {
TransactionID string
Status string
}import paymentapi "company.com/payment-api"
response, err := a.Call(paymentPID, paymentapi.ChargeRequestV1{
OrderID: order.ID,
Amount: order.Total,
})events/
├── OWNERS.md # who approves changes
├── CHANGELOG.md # version history
└── order/
├── created_v1.go
└── created_v2.go# OWNERS.md
Maintainers (approve all changes):
- platform-team
Reviewers (approve breaking changes):
- order-team
- payment-team
- analytics-teamcompany.com/
│
├── events/ # cluster-wide events
│ ├── go.mod # module company.com/events
│ ├── order_created_v1.go
│ ├── order_created_v2.go
│ ├── payment_received_v1.go
│ └── register.go
│
├── payment-api/ # Payment Service contract
│ ├── go.mod # module company.com/payment-api
│ ├── charge_v1.go
│ └── refund_v1.go
│
├── order-service/
│ ├── go.mod # requires: events, payment-api
│ ├── internal/
│ └── cmd/
│
└── payment-service/
├── go.mod # requires: events
├── internal/
└── cmd/company.com/
│
├── messaging/ # cluster-wide events and contracts
│ ├── v1/
│ │ ├── events/
│ │ │ ├── go.mod # module company.com/messaging/v1/events
│ │ │ ├── order_created.go
│ │ │ └── payment_received.go
│ │ └── payment/
│ │ ├── go.mod # module company.com/messaging/v1/payment
│ │ └── charge.go
│ └── v2/
│ ├── events/
│ │ ├── go.mod # module company.com/messaging/v2/events
│ │ └── order_created.go
│ └── payment/
│ ├── go.mod # module company.com/messaging/v2/payment
│ └── charge.go
│
├── order-service/
│ ├── go.mod # requires: messaging/v1/events, messaging/v1/payment
│ ├── internal/
│ └── cmd/
│
└── payment-service/
├── go.mod # requires: messaging/v1/events
├── internal/
└── cmd/// events/register.go
package events
import "ergo.services/ergo/gen"
func RegisterTypes(network gen.Network) error {
return network.RegisterTypes([]any{
OrderCreatedV1{},
OrderCreatedV2{},
PaymentReceivedV1{},
})
}import "company.com/events"
func (a *OrderService) Load(args ...any) (gen.ApplicationSpec, error) {
if err := events.RegisterTypes(a.Node().Network()); err != nil {
return gen.ApplicationSpec{}, err
}
return gen.ApplicationSpec{ /* ... */ }, nil
}Rename a field (same type)
Compatible
Compatible
Add a field at the end
New version
// Start from the defaults and add the flag. Building NetworkFlags from scratch
// would turn every other capability off (important delivery, fragmentation,
// simultaneous connect, clock skew, tracing, wrapped errors, keepalive).
flags := gen.DefaultNetworkFlags
flags.EnableSchemaEvolution = true
gen.NodeOptions{
Network: gen.NetworkOptions{Flags: flags},
}// Before
type OrderCreated struct {
OrderID int64
}
// After - Priority appended at the end. Same type, same identity.
type OrderCreated struct {
OrderID int64
Priority int
}// Deprecated: use ChargeRequestV2. Remove after 2025-Q3.
type ChargeRequestV1 struct {
OrderID int64
Amount int64
}case ChargeRequestV1:
a.Log().Warning("deprecated ChargeRequestV1 from %s", from)
return a.handleChargeV1(m)// internal/acl/charge.go
package acl
import api "company.com/payment-api"
func ChargeV1ToV2(v1 api.ChargeRequestV1) api.ChargeRequestV2 {
return api.ChargeRequestV2{
OrderID: v1.OrderID,
Amount: v1.Amount,
Currency: "USD", // default for V1 clients
}
}func (a *Actor) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case api.ChargeRequestV1:
v2 := acl.ChargeV1ToV2(m)
return a.processCharge(v2)
case api.ChargeRequestV2:
return a.processCharge(m)
}
return nil
}func TestPaymentActorAcceptsBothVersions(t *testing.T) {
sub, err := unit.Spawn(t, createPaymentActor, gen.ProcessOptions{})
check.NoError(t, err)
client := gen.PID{Node: "test@localhost", ID: 100, Creation: 1}
// V1 works
sub.SendMessage(client, ChargeRequestV1{OrderID: 1, Amount: 100})
sub.ShouldSend().Message(ChargeResponseV1{}).Once().Assert()
// V2 works
sub.SendMessage(client, ChargeRequestV2{OrderID: 2, Amount: 200, Currency: "EUR"})
sub.ShouldSend().Message(ChargeResponseV2{}).Once().Assert()
}func TestACLConvertsV1ToV2(t *testing.T) {
v1 := ChargeRequestV1{OrderID: 123, Amount: 500}
v2 := acl.ChargeV1ToV2(v1)
assert.Equal(t, v1.OrderID, v2.OrderID)
assert.Equal(t, v1.Amount, v2.Amount)
assert.Equal(t, "USD", v2.Currency) // default
}type MessageOrderShippedV1 struct {
OrderID int64
TrackingN string
}type ChargeRequestV1 struct {
OrderID int64
Amount int64
}
type ChargeResponseV1 struct {
TransactionID string
Status string
}type OrderCreatedV1 struct { ... }
type PaymentReceivedV1 struct { ... }type OrderV1 struct { ... } // correct
type Order struct { ... } // avoid - unclear versioning
type OrderNew struct { ... } // avoid - not a version number// Wrong under the strict default - breaks existing consumers
type Order struct {
ID int64
Priority int // appended field changes the wire format
}
// Correct - create new version (in type name or new package path)
type OrderV2 struct {
ID int64
Priority int
}// Type exists but not registered. Encoding fails at runtime.
type OrderV3 struct { ... }
// Register from your application's Load callback before any traffic.
node.Network().RegisterType(OrderV3{})Nature
Service API contract
Domain fact
Owner
Receiver (implements logic)
MailboxLatency field in ProcessShortInfo for per-process latency snapshots
Node.ProcessRangeShortInfo() for efficient iteration over all processes with their latency data
Counters visible via Network().RegisteredTypes() API and the Observer Types panel
Use structured logging: The framework's logging system supports log levels and structured fields. Add context with AddFields() for correlation:
For scoped logging, use PushFields()/PopFields() to save and restore field sets.
Profile regularly: Periodic profiling during development helps catch performance regressions before production.
Test shutdown paths: Explicitly test graceful shutdown to verify all actors terminate cleanly.
/debug/pprof/goroutine
Stack traces of all goroutines
/debug/pprof/heap
Heap memory allocations
/debug/pprof/profile
go run --tags norecover ./cmdgo run --tags verbose ./cmdoptions := gen.NodeOptions{
Log: gen.LogOptions{
Level: gen.LogLevelTrace,
},
}go run --tags latency ./cmdgo run --tags typestats ./cmdgo run --tags "pprof,norecover,verbose" ./cmdgo run --tags "pprof,latency" ./cmdgo run --tags "pprof,latency,typestats" ./cmd# Find actor goroutines by PID
curl -s "http://localhost:9009/debug/pprof/goroutine?debug=1" | grep -B5 'labels:.*pid'
# Find meta process goroutines by Alias
curl -s "http://localhost:9009/debug/pprof/goroutine?debug=1" | grep -B5 'labels:.*meta'1 @ 0x100c17fa0 0x100c18abc 0x100c19def ...
# labels: {"pid":"<ABC123.0.1005>"}
# main.(*Worker).HandleMessage+0x27 /path/worker.go:451 @ 0x100c17fa0 0x100c18abc 0x100c19def ...
# labels: {"meta":"Alias#<ABC123.0.1.2>", "role":"reader"}
# main.(*TCPServer).Start+0x1bc /path/tcp_server.go:52goroutine 38669 [chan receive] {pid: "<ABC123.0.1041>"}:
ergo.services/ergo/act.(*Actor).ProcessRun(0x140003c2000)
/path/act/actor.go:259 +0x758
...
goroutine 24812 [IO wait] {meta: "Alias#<ABC123.107118.6819740677833.0>", role: reader}:
internal/poll.runtime_pollWait(0x112aec600, 0x72)
/usr/local/go/src/runtime/netpoll.go:351 +0xa0
...//go:debug tracebacklabels=1
package main[warning] node myapp@localhost is still waiting for process(es) to terminate:
[warning] <ABC123.0.1005> (worker, myapp.Worker) state: running, queue: 5
[warning] <ABC123.0.1012> (myapp.DBConn) state: running, queue: 0
[warning] <ABC123.0.1018> (myapp.Reporter) state: sleep, queue: 0curl -s "http://localhost:9009/debug/pprof/goroutine?debug=2" > goroutines.txtgrep -A30 'pid.*ABC123.0.1005' goroutines.txtruntime.chanrecv1
/usr/local/go/src/runtime/chan.go:442sync.(*Mutex).Lock
/usr/local/go/src/sync/mutex.go:81internal/poll.(*FD).Read
/usr/local/go/src/internal/poll/fd_unix.go:163ergo.services/ergo/node.(*process).waitResponse
/path/node/process.go:1961options := gen.NodeOptions{
ShutdownTimeout: 30 * time.Second, // shorter timeout for debugging
}curl -s "http://localhost:9009/debug/pprof/heap" > heap.prof
go tool pprof heap.prof(pprof) top 10(pprof) list HandleMessagepanic: runtime error: invalid memory address or nil pointer dereference
goroutine 42 [running]:
main.(*MyActor).HandleMessage(0x140001a2000, {0x100d12345, 0x140001b0000})
/path/myactor.go:45 +0x1bcCluster-wide events
Shared
events/
All consumers
Compatible
Remove a field from the end
New version
Compatible
Insert, reorder, retype, or remove a field elsewhere
New version
New version
Shared (belongs to domain)
Module
receiver-api/
events/
Changes
Receiver team decides
All consumers coordinate
CPU profile (30-second sample)
/debug/pprof/block
Goroutine blocking events
/debug/pprof/mutex
Mutex contention
func (a *MyActor) HandleMessage(from gen.PID, message any) error {
log := a.Log()
log.AddFields(
gen.LogField{Name: "request_id", Value: requestID},
gen.LogField{Name: "user_id", Value: userID},
)
defer log.DeleteFields("request_id", "user_id")
log.Info("processing request")
// all log messages now include request_id and user_id
return nil
}Making distributed communication feel local
Network transparency means the location of a process - whether it's in the same goroutine, on the same node, or on a remote node halfway across the world - doesn't change how you interact with it. You send messages the same way. You make calls with the same API. You establish links and monitors with the same methods. The framework handles the complexity of discovering nodes, encoding messages, and routing them across the network.
This isn't just convenient. It's fundamental to building distributed systems in the actor model. If remote operations looked different from local operations, you'd be constantly checking location and branching your logic. That locality awareness would spread throughout your code, making it brittle and hard to reason about. Network transparency lets you design systems as collections of communicating actors, and deployment topology becomes an operational concern rather than a code concern.
But transparency has limits. Networks are slower than in-process communication. They fail in ways local operations don't. Messages can be lost. Connections drop. Remote nodes crash or become unreachable. The framework makes remote operations look local, but the network's physical reality still matters.
Consider a simple example. You have a gen.PID and you want to send it a message:
process.Send(pid, OrderRequest{OrderID: 12345, Items: []string{"item1", "item2"}})This code is identical whether pid points to a local process or a remote one. You don't check. You don't call different methods. You just send.
Behind the scenes, the framework does different things:
For a local process: The message is placed directly in the recipient's mailbox queue. The framework checks the priority, selects the appropriate queue (Main, System, or Urgent), and pushes the message. If the process is sleeping, it wakes up. The entire operation happens in microseconds.
For a remote process: The node extracts the node name from the gen.PID, checks if a connection to that node exists, discovers the node's address if needed, establishes a connection pool if necessary, encodes your OrderRequest using EDF, wraps it in a protocol frame, sends it over TCP, and waits for the remote node to acknowledge delivery. The remote node receives the frame, decodes it, routes it to the recipient's mailbox, and sends an acknowledgment back. This takes milliseconds.
From your code's perspective, both operations look identical. The framework abstracts the complexity.
Network transparency is an illusion carefully maintained by the framework. Several mechanisms work together to create this effect.
Unified addressing - Every process has a gen.PID that includes the node name. Local and remote processes have the same identifier structure. You don't need different types for "local process" and "remote process". A gen.PID is just a gen.PID, and it works everywhere.
Automatic routing - When you send to a process, the framework examines the node portion of the identifier. If it matches the local node, the message is delivered locally. If it doesn't match, the framework initiates discovery to find the remote node and routes the message over the network. You don't trigger this logic explicitly - it happens automatically.
Location independence - You can receive a gen.PID from anywhere - as a return value, in a message, from a registry lookup - and immediately use it for communication. You don't need to check where it's from or set up connections. The framework handles it.
Failure semantics - When you send to a local process that doesn't exist, you get an error immediately. When you send to a remote process that doesn't exist, you get... nothing, by default. The message is sent over the network, and if nobody's listening, it's silently dropped. This asymmetry breaks the transparency illusion. The Important delivery flag fixes this: with Important enabled, sending to a missing remote process gives you an immediate error, just like local delivery. The framework makes the network behave like local memory.
When you send a message to a remote process, what actually happens? The framework performs a complex series of operations to transform your Go value into bytes, transmit them over TCP, and reconstruct them on the receiving side. Understanding this flow helps you design efficient distributed systems and debug problems when they arise.
The sequence diagram below shows the complete message transmission pipeline, from the moment you call Send to the moment the recipient's HandleMessage is invoked:
When you send a message, the framework:
Encodes your value using EDF, transforming it into a byte sequence
Compresses it if the message exceeds the compression threshold (default 1024 bytes)
Frames it with protocol headers containing metadata (message type, sender, recipient, priority)
Transmits the frame over one of the TCP connections in the pool to the remote node
The remote node reverses this:
Reads the frame from the TCP connection
Decompresses if the compression flag is set
Decodes the bytes back into a Go value using EDF
Routes the message to the recipient's mailbox
This entire pipeline is invisible. You call Send, and the framework executes these steps. The receiving process calls HandleMessage, and it receives your value as if you'd passed it locally.
EDF (Ergo Data Format) is a binary serialization format designed for distributed actor systems. It solves a fundamental problem: how do you serialize Go values - structs, slices, maps, framework types like gen.PID - across the network with the performance of code-generated serializers like Protocol Buffers, but without requiring code generation?
The answer is dynamic specialization. When you register a type, EDF analyzes its structure and builds specialized encoding and decoding functions specifically for that type. For structs, it creates functions for each field and composes them into a single encoder. This happens once at registration time, not during encoding. When you send a message, EDF uses these pre-built functions - no reflection, no runtime type analysis.
This approach delivers Protocol Buffers-class performance without .proto files or protoc code generation.
Registration happens at runtime - no build step, no generated files. You call node.Network().RegisterType() from your application's Load() callback, and the framework builds the optimized encoders. Framework types like gen.PID, gen.Ref, and gen.Event have native support with specialized encodings. During node handshake, both sides exchange their registered type lists and negotiate short numeric IDs, turning a full type name into 3 bytes on the wire. Field names aren't encoded - only field values in declaration order.
Performance benchmarks (see benchmarks/serial/) show encoding is 50-100% faster than Protocol Buffers, while decoding is 20-60% slower. The encoding advantage comes from the specialized functions built during registration.
EDF enforces strict type contracts - both nodes must register identical type definitions. Type identity is the full package path plus type name, not just the type name. For example, Order in package github.com/myapp/orders becomes #github.com/myapp/orders/Order. Two packages with the same type name Order are different types in EDF - this is Go's type system enforced at the protocol level.
This strict typing is a deliberate design choice that pushes version management to the application level. When you need to evolve a message type, you version it explicitly in your code:
Your actors handle both versions, routing logic based on the type received. Each node declares what it understands, and the application code manages compatibility - explicit and visible. This strict-by-default behavior suits contracts where every change should be a deliberate decision.
When your domain favors deployment velocity instead, the EnableSchemaEvolution network flag opts into protocol-level tolerance for appended fields: enabled on both nodes, adding a field to the end of a struct keeps the same type, and a node that has not learned the field skips it. It is a per-connection capability, off by default. Which model fits is a business decision - see .
EDF imposes size limits on certain types. These limits balance memory safety with practical message sizes.
Atoms (gen.Atom) - Maximum 255 bytes. Atoms are used for names - node names, process names, event names. Names longer than 255 bytes are uncommon and likely indicate a design issue. The 255-byte limit keeps name handling efficient.
Strings - Maximum 65,535 bytes (2^16-1). This covers most string use cases. For larger text (documents, logs, large payloads), use binary encoding ([]byte) instead, which supports up to 4GB.
Errors - Maximum 32,767 bytes (2^15-1). Error messages longer than 32KB are unusual. If you need to send detailed diagnostic information, use a separate field in your message struct.
Binary ([]byte) - Maximum 4,294,967,295 bytes (2^32-1, ~4GB). This is the largest single value EDF can encode. Messages containing multi-gigabyte binaries work but are inefficient. Consider chunking large data into multiple messages or using meta processes for streaming.
Collections (map, array, slice) - Maximum 2^32 elements. A map can have up to 4 billion entries. A slice can have 4 billion elements. These limits are unlikely to be hit in practice - a slice of 4 billion int64 values would consume 32GB of memory.
Evolvable structs - With the EnableSchemaEvolution network flag active on a connection, a single encoded struct is bounded at just under 4GB. Without the flag there is no separate per-struct size cap. See .
These limits are enforced during encoding. If you attempt to encode a 70,000 byte string, the encoder returns an error. The message isn't sent. On the receiving side, if a malicious sender tries to send an oversized value, the decoder rejects it and closes the connection.
For custom types to cross the network, both sending and receiving nodes must register them. Registration tells the active wire-format proto how to encode and decode the type, and creates a numeric ID that's shared during handshake for efficient encoding.
The preferred way is to declare wire-format values in the application's spec. The framework registers them during ApplicationLoad, before any process in the application is spawned:
If the node's network mode is NetworkModeDisabled, the entries are silently ignored. The application loads as usual.
The imperative form remains available for dynamic cases (registering types based on runtime configuration):
Network().RegisterType distributes registration across every active wire-format proto (e.g., the default ENP/EDF stack). If your node has multiple wire-format protocols configured (for example, a previous-generation ENP and a newer one running side by side), one call registers in all of them. The call fails if any proto rejects the type. Wire-format consistency is enforced strictly to prevent silent split-brain registries.
For batch registration of multiple types, use RegisterTypes (see the Nested types subsection below for the dependency-resolution behavior):
RegisterTypes resolves inter-type dependencies internally. You can list types in any order, and the framework figures out the correct registration sequence. The same is true for the Network.RegisterTypes field in ApplicationSpec: order in the slice is irrelevant.
Only exported fields - Structs must have all fields exported (starting with uppercase). This is by design: exported fields define your actor's contract. When actors communicate - locally or across the network - they exchange messages according to explicit contracts. Unexported fields are implementation details, internal state that shouldn't cross actor boundaries. If registration encounters unexported fields, it fails with "struct Order has unexported field(s)".
Excluding fields from wire encoding - If your struct must hold internal state alongside its public contract (caches, file handles, runtime pointers, anything that doesn't make sense for a remote actor), tag those fields with edf:"-". Tagged fields are skipped during encode and left as their zero value during decode. The tag works on both exported and unexported fields, so it is also the way to register a type with private internal state.
Without edf:"-" the unexported items would cause registration to fail. With it, only ID participates in the wire format. This is the right escape hatch when the type as a whole must travel across the network but specific fields cannot be serialized.
Pointer types - Starting from version 3.3, EDF supports pointer types. Pointers can be nil or point to a value, and this state is preserved during encoding/decoding. Nested pointers (**int) are not supported.
Note that pointers to external resources like *Database or *Connection are meaningless to a remote actor - it cannot dereference your memory address. Use pointers for optional value semantics, not for sharing local resources. For distributed references, use framework types: gen.PID, gen.Alias, gen.Ref.
Nested types - If your type contains other custom types, the inner types must be registered before the outer type. Use RegisterTypes (batch) which resolves dependency order automatically:
If you call RegisterType (singular) on Person before Address, registration fails with "type Address must be registered first". With RegisterTypes, the framework iteratively retries pending types whose dependencies become available. Only types that genuinely cannot be resolved produce an error. Registration builds the encoding schema by examining fields; once Address is registered, registering Person references its schema for efficient nested encoding.
If you only need to exclude specific fields, prefer the edf:"-" tag (see above) - it is lighter than implementing a full marshaler. Reach for custom marshaling when the type itself needs an alternative on-wire representation, for example to compact a complex value, to maintain backward compatibility with an old wire format, or to integrate with an external serialization scheme.
EDF supports both edf.Marshaler/Unmarshaler and Go's standard encoding.BinaryMarshaler/Unmarshaler interfaces. The key difference is performance: edf.Marshaler writes directly to EDF's internal buffer (io.Writer), avoiding intermediate allocations. When you call MarshalEDF(w), the io.Writer is EDF's reusable buffer - your bytes go straight to the wire. With encoding.BinaryMarshaler, you must allocate and return a []byte, which EDF then copies into its buffer.
For high-throughput message types, prefer edf.Marshaler. For types that implement standard interfaces or rarely-sent messages, encoding.BinaryMarshaler works fine.
Go's error type is an interface, which means encoding it across the network requires extra care. Two things matter to user code: the error text on the receiver, and whether errors.Is against a known sentinel still works.
Framework errors in the gen.Err* set (gen.ErrProcessUnknown, gen.TerminateReasonNormal, gen.ErrExceeded, and the rest) are pre-registered out of the box. Their identity is preserved automatically across nodes. The act.Err* set is local to the actor library and not pre-registered for the wire: those errors are returned only from local management APIs and don't cross the network in the default framework path.
Application errors must be registered on every node that needs to compare against them. The declarative form lives in ApplicationSpec.Network:
Imperative equivalents are Network().RegisterError (single) and Network().RegisterErrors (batch). The same shape applies to atoms via Network.RegisterAtoms and Network().RegisterAtom/Network().RegisterAtoms.
If both peers have a sentinel registered, the receiver decodes it to its own local instance, so errors.Is(err, ErrInvalidOrder) returns true across the network. If the sender has not registered it, the error arrives with the correct text but a fresh identity, and errors.Is against the original sentinel returns false. Code that branches on identity needs the sentinel registered on the sender side.
Sentinels have to be errors.New values. gen.Errorf produces a *gen.Error, which the error cache refuses to register: the encoder has a dedicated branch for *gen.Error and takes it before it consults the cache, so a registered chain would still be encoded field by field and rebuilt as a new value on the receiver, leaving errors.Is false against the original. The text arrives intact, which is what makes this mistake invisible in logs and in single-node tests.
Structure is the other limit. In a field declared as error, two things keep it: a sentinel value registered on both peers, and *gen.Error. A concrete error type of your own keeps nothing - RegisterType refuses any type that implements error, and the error encoder consults neither the type registry nor a custom MarshalEDF, so such a value goes on the wire as its Error() text and arrives as a plain error. Anything structured that the receiver has to compute with belongs in a typed field beside the error rather than inside it.
fmt.Errorf("...: %w", err) is the standard Go idiom for wrapping. Locally it works as expected: errors.Is and errors.Unwrap follow the chain. Across the network, the chain collapses to a flat string: the receiver sees the correct err.Error() text, but errors.Is(err, originalMarker) returns false and errors.Unwrap(err) returns nil.
gen.Errorf is the drop-in replacement that preserves the chain end-to-end:
Reading such an error on the receiver is no different from reading it locally; the shapes a chain can take and the rules for inspecting them are described under .
Multiple %w (Go 1.20+) work the same way: gen.Errorf("%w and %w", a, b) makes both a and b reachable via errors.Is.
For wrapped markers to keep identity, the same registration rule applies as for a bare sentinel: each %w marker must be registered on both peers. Otherwise the message text is intact but the wrapped sentinel arrives as a fresh instance.
All the shapes a wrap chain can take survive the trip - one cause, several at one level, nesting to any depth. The encoder walks Wrapped recursively and encodes each cause the way it would encode a top-level one: by cache id when it is a registered marker, structurally when it is itself a *gen.Error. Depth is bounded by Options.MaxDepth.
What does not survive is the identity of an intermediate *gen.Error. Only registered markers keep identity, and a nested chain is rebuilt on the receiver as a new value, so errors.Is against a package-level *gen.Error is false on the far side while being true locally. An error contract therefore has to be exercised across a real connection or an edf.Encode/edf.Decode round-trip: a single-node test passes either way.
Cross-network preservation of the chain is gated by NetworkFlags.EnableWrappedErrors, which is on by default. When either peer has it disabled (typically an older node), gen.Errorf falls back to flat-text behavior just like fmt.Errorf would.
Three different losses look identical on the receiver - the text is intact and errors.Is returns false: a sentinel the sender never registered, a peer with EnableWrappedErrors disabled, and an error the encoder cannot represent structurally. When identity stops matching, check registration on both sides before anything else.
errors.Join(a, b) is not preserved across the network. Use gen.Errorf("%w\n%w", a, b) if you need identity for multiple causes.
A wrap chain does not describe causality, it describes membership. Every %w marker states that this failure belongs to that group, and errors.Is is the membership test. The set is flat - the wire carries markers, not a hierarchy - so the sender has to state every membership the receiver is going to test.
That makes two levels the natural shape for an error contract: a narrow marker for the specific failure, and a broader one shared by everything of that kind.
The receiver tests whichever level it cares about, with no string handling either way:
The property worth having is what happens when the two sides drift apart: the group marker is registered independently of its members, so a receiver that knows only the group correctly classifies failures it has never heard of. The sender can add a member without a coordinated release, and the specific text is still in the message for whoever reads the log. Only a new group marker needs both sides to agree.
Expressing the hierarchy in the data instead does not work. A group marker built as gen.Errorf("code:1234: %w", ErrInvalidArgB) cannot be registered, so it arrives as a freshly built value: the marker at the bottom of the chain still matches, the group itself no longer does. Wrap both markers at the call site, or keep the member-to-group mapping as a table on the receiving side.
Two consequences follow from the set being flat. A membership the sender forgot to state is simply absent, and the receiver's coarse test returns false with nothing to say why. And a receiver that walks a list of markers and takes the first match has to order that list deliberately, most specific first, because the wire can carry a member and its group side by side.
Type registration must happen before connection establishment. During handshake, nodes exchange their registered type lists and error lists. These lists become the encoding dictionaries for that connection.
Registering a type after a connection is established does not break that connection. The dictionary is a compression device, not a gate: when a type has no cache id for this connection, the encoder writes its full canonical name instead, and the receiver resolves that name against the types it has registered. Nothing checks the peer's list before sending.
What the missing entry costs is size, and what it requires is symmetry. The name travels with every message rather than a two-byte id, and the receiving node must have registered the same type - otherwise it fails there, at decode, with "unknown reg type". Reconnecting is worth doing to get the compact ids back on a hot path, not to make the type usable.
The recommended place to register types is the application's Load callback. Applications are loaded after the network stack is initialized but before any outgoing or incoming traffic, so all types end up in the handshake dictionaries. An application owns its message types and registers them itself, keeping registration co-located with the code that defines the types.
For dynamic type registration (registering types based on runtime configuration or plugin loading), the options differ in cost rather than in whether they work:
Register before any traffic - Load your configuration, determine which types you need, register them in your application's Load() callback. This is the one that gets you cache ids on every connection.
Register late on both sides - node.Network().RegisterType on each node that will encode or decode the type. Messages flow immediately, carrying the full type name until the next handshake replaces it with an id. Reconnect if that overhead matters on the path in question.
Use custom marshaling - Implement edf.Marshaler/Unmarshaler or encoding.BinaryMarshaler/Unmarshaler. These don't require pre-registration - they work immediately. The tradeoff is you write the encoding logic yourself.
Most applications register types statically from Load() and avoid these complications.
Earlier versions of the framework exposed registration as package-level functions on ergo.services/ergo/net/edf:
These functions remain for backward compatibility but are deprecated. They write directly into the EDF package state, bypassing the gen.Network abstraction. In a multi-proto setup (more than one wire-format proto registered on the node), they only register in EDF, and other protos won't see the type. The new Network API distributes registration to every active wire-format proto strictly.
Prefer the declarative ApplicationSpec.Network field, or, for dynamic cases, node.Network().RegisterType / RegisterTypes / RegisterError / RegisterErrors / RegisterAtom / RegisterAtoms from your application's Load() callback. The package-level functions emit a one-time deprecation warning when called from user code.
Large messages are automatically compressed to reduce network bandwidth. Compression is transparent - you configure it on the process or node, and the framework applies it automatically when appropriate.
When compression is enabled, the framework checks the encoded message size before transmission. If it exceeds the compression threshold (default 1024 bytes), the message is compressed using the configured algorithm. The protocol frame's message type (byte 7) is set to 0xc8 (200, protoMessageZ) and byte 8 contains the compression type ID (100=LZW, 101=ZLIB, 102=GZIP), so the receiving node knows to decompress before decoding.
Configure compression in process options:
Or adjust it dynamically:
Type determines the compression algorithm. GZIP (ID=102) provides good compression ratios with reasonable speed. ZLIB (ID=101) is similar but with slightly different format. LZW (ID=100) is faster but produces lower compression. Choose based on your CPU/bandwidth tradeoff.
Level trades compression time for compression ratio. CompressionBestSize produces smaller messages but takes longer. CompressionBestSpeed compresses quickly but produces larger output. CompressionDefault balances both.
Threshold sets the minimum size for compression. Messages smaller than the threshold aren't compressed, even if compression is enabled. Compressing tiny messages adds overhead without reducing size meaningfully. The default 1024 bytes is reasonable - messages below 1KB go uncompressed, larger messages get compressed.
Compression happens per-message. Each message is independently compressed or not, based on its size. This keeps compression stateless and allows the receiver to decode messages in any order.
During handshake, nodes exchange caching dictionaries for frequently used values. This caching reduces message sizes significantly.
Atom caching - Node names, process names, event names - these atoms appear repeatedly in messages. Every gen.PID contains the node name. Every message frame contains sender and recipient identifiers. Instead of encoding "mynode@localhost" repeatedly (2-byte length + 17 bytes = 19 bytes), the handshake assigns it a numeric ID. Cached atoms encode as 2 bytes (uint16 ID, where ID > 255). All subsequent uses of that atom encode as the 2-byte ID.
Type caching - Registered types get numeric IDs. A User struct registered on both sides gets an agreed-upon ID. Messages containing User values encode the ID instead of the full type name and structure. A typical struct name like "#mypackage/User" might be 20-30 bytes - cached, it's 3 bytes (0x83 + 2-byte cache ID where ID > 4095).
Error caching - Registered errors get IDs. Framework errors are pre-registered with well-known IDs. Custom errors get IDs during handshake. Error responses that might encode as 50+ bytes (error string message) encode as 3 bytes with caching (type tag + 2-byte ID where ID > 32767).
The caches are bidirectional - both nodes maintain the same mappings. During encoding, the sender looks up the cache and uses IDs. During decoding, the receiver looks up IDs and reconstructs values. The cache persists for the connection lifetime. If the connection drops and reconnects, a new handshake creates a new cache.
This caching is automatic. You don't manage the cache or invalidate entries. The framework handles it. You just benefit from smaller messages.
To measure how much each registered type actually contributes to network traffic and to identify candidates for compression, build the node with -tags=typestats. This enables per-type encode/decode counters and wire-byte totals exposed via Network().RegisteredTypes() and visible in the Observer Types panel. Counters increment only on root operations (a type sent or received as a message in its own right); bytes embedded inside other messages are accounted to the parent type. The cost is approximately 2-3% on encode/decode throughput; without the tag there is zero overhead. See for details.
Network transparency breaks down when dealing with failures. Sending to a local process that doesn't exist returns an error immediately - the framework checks the process table and sees the PID isn't registered. Sending to a remote process that doesn't exist returns... nothing. The message is encoded, sent to the remote node, and the remote node silently drops it because there's no recipient. Your code doesn't know the process was missing.
This asymmetry makes debugging difficult. Is the remote process slow to respond, or does it not exist? Did the message get lost in the network, or was it never received? The fire-and-forget nature of normal Send provides no feedback.
The Important delivery flag fixes this:
With Important delivery:
The message is sent to the remote node with an Important flag in the frame (bit 7 of priority byte set)
The remote node attempts delivery to the recipient's mailbox
If delivery succeeds, the remote node sends an acknowledgment back
If delivery fails (no such process, mailbox full), the remote node sends an error response back
If the acknowledgment arrives, SendImportant returns nil. If an error response arrives, it returns the error. If the timeout expires, it returns gen.ErrTimeout.
This gives you the same semantics as local delivery: immediate error feedback when something goes wrong. The network becomes transparent for failures too, not just successes.
The cost is latency. Normal Send returns immediately - it queues the message and continues. SendImportant blocks until the remote node responds, adding a network round-trip. For messages that must be delivered, this cost is worth it. For best-effort messages where occasional loss is acceptable, stick with normal Send.
For detailed exploration of Important Delivery patterns, reliability guarantees, and protocols like RR-2PC and FR-2PC, see .
Messages sent from process A to process B arrive in sending order. This is a per-sender FIFO guarantee; it applies to each sender independently, not globally across all senders. The guarantee is enabled by default for every process.
Message ordering is controlled by a per-process flag called KeepNetworkOrder, which defaults to true. You can change it using SetKeepNetworkOrder(bool) during Init or at any point while the process is running. The flag applies to all outgoing messages from that process: Send, Call, SendResponse, and SendEvent. There is no per-message override; ordering is all-or-nothing for a given sender.
With ordering enabled, all messages from a process go through the same TCP link in the connection pool. The link is selected deterministically from an order value the sender computes as sender.ID % 255 + 1, then order % pool_size picks the link. Since TCP guarantees FIFO delivery within a single connection, messages arrive at the remote node in exactly the order they were sent.
The + 1 is not cosmetic: zero is the reserved "unordered" value. With ordering disabled the sender writes 0, and both ends read that as "no ordering requested" - the sender takes the round-robin path across all pool links, and the receiver picks its decoding queue by arrival instead of by order value. This spreads the load for maximum throughput, but the arrival order across different TCP connections is no longer deterministic.
Each message carries an order byte in the protocol header (byte 6 of the ENP frame). Which identity it is derived from depends on how the message was addressed:
For gen.PID recipients: to.ID % 255 + 1 - the recipient's
For gen.Alias recipients: to.ID[1] % 255 + 1 - the recipient's
For a name-addressed send, the sender's own value, because the recipient's id is not known at that point
The receiving node routes messages to receive queues based on this byte: order_byte % queue_count. Messages carrying the same value land in the same queue and are decoded sequentially, preserving order.
When ordering is disabled the byte is zero, which the receiver treats as "no ordering": messages distribute round-robin across receive queues, enabling parallel decoding at the cost of non-deterministic arrival order. That is why the computed values start at 1 rather than 0.
The ordering mechanism works at two levels:
Sender side: pins messages to one TCP link, preserving send order in the TCP stream
Receiver side: pins messages to one decode queue, preserving decode order
Together they ensure end-to-end FIFO from sender to recipient. The sender side prevents reordering during transmission; the receiver side prevents reordering during decoding and dispatch.
Some system messages have fixed ordering semantics regardless of the KeepNetworkOrder flag:
These are internal system messages where ordering behavior is fixed by the protocol, not configurable by the process.
Processes that don't need ordering benefit from disabling it. When KeepNetworkOrder is false, messages spread across all TCP links in the pool and all receive queues on the remote side. This increases parallelism on both ends: more connections are utilized for sending, and more goroutines participate in decoding.
Good candidates for disabling ordering:
Stateless workers that process each request independently
Fan-out producers that distribute work to many recipients
High-throughput event emitters where each event is self-contained
The tradeoff is straightforward: message arrival order becomes non-deterministic. If your process logic doesn't depend on message order, disabling ordering gives you better throughput.
EDF-encoded messages are wrapped in ENP (Ergo Network Protocol) frames for transmission over TCP.
Each frame has an 8-byte header:
Byte 0: Magic byte (78 for ENP)
Byte 1: Protocol version (1 for current version)
Bytes 2-5: Frame length (uint32, total size in bytes)
Byte 6: Order byte (derived from the recipient for PID and Alias sends, from the sender for name-addressed sends; 0 means unordered)
For PID messages, the frame contains:
Sender PID (8 bytes - just the ID, node is known from connection)
Priority byte (bits 0-1 = priority 0-2, bit 7 = Important delivery flag; the bits in between are not read)
Reference (8 bytes - first uint64 of Ref.ID; always present, written only when the Important bit is set)
Recipient PID (8 bytes)
The order byte (byte 6) controls message ordering and receive queue routing. For details on how the order byte is calculated and how it interacts with the connection pool and receive queues, see above.
Network transparency is powerful but not magical. The network has physical properties that can't be abstracted away.
Latency - Remote operations are slower. A local Send takes microseconds. A remote Send takes milliseconds. That's three orders of magnitude. For a single message, it's negligible. For thousands of messages, the difference is dramatic. Design systems to minimize remote calls, batch operations, and use asynchronous patterns.
Bandwidth - Network links have finite capacity. Sending millions of small messages can saturate a network connection. Encoding and decoding adds CPU overhead. Compression helps but costs CPU time. Be mindful of message volume and size. Local operations have effectively infinite bandwidth - remote operations don't.
Failures - Networks fail in ways local memory doesn't. Packets get lost. Connections drop. Nodes become unreachable. DNS fails. Firewalls block traffic. Local operations either succeed instantly or fail with a clear error. Remote operations can timeout, leaving you uncertain whether they succeeded. Design for these failure modes with timeouts, retries, and idempotent operations.
Partial failures - In a distributed system, some nodes can fail while others continue working. A local system either works entirely or crashes entirely. A distributed system can be partially operational - some nodes reachable, others not. This partial failure is the hardest aspect of distributed systems. The framework can't hide it entirely.
Ordering - Message ordering is preserved per-sender, not globally. Messages from process A to process B arrive in sending order, but messages from different senders can interleave arbitrarily. If a connection drops and reconnects, messages sent during disconnection are lost or delayed. Don't assume global ordering across the cluster. See for how the ordering mechanism works and when to disable it.
Network transparency makes distributed programming feel local. But distributed programming has fundamental differences from local programming. The transparency is a tool that simplifies common cases - it doesn't eliminate the need to think about distributed system challenges.
Understanding network transparency helps you design better distributed systems.
Use local clustering - Group processes that communicate frequently on the same node. If processes exchange hundreds of messages per second, put them locally. Their communication is microseconds instead of milliseconds, and you avoid network overhead.
Prefer async over sync - Use Send (asynchronous) instead of Call (synchronous) for remote communication when possible. Async messaging doesn't block the sender, improving throughput. Sync calls over the network tie up your process waiting for responses.
Design for message batching - Send one message with 100 items instead of 100 messages with 1 item each. Network overhead is per-message. Batching amortizes that overhead.
Handle failures explicitly - Use timeouts on sync calls. Use Important delivery for critical messages. Monitor connection health. Don't assume remote operations succeed - check errors and have fallback logic.
Keep messages small - Encoding and network transmission costs scale with message size. Large messages cause memory allocation, encoding overhead, network congestion. If you're sending megabytes of data, consider whether it belongs in messages or should use a different mechanism (file transfer, streaming, database).
Leverage compression - Enable compression for processes that send large messages. The CPU cost of compression is usually worth the network bandwidth savings. But don't compress tiny messages - the overhead exceeds the benefit.
Register types early - Do all type registration from your application's Load callback so types are in the registry before any traffic. Avoid dynamic type registration that requires connection cycling. Static registration is simpler and more reliable.
For details on how the network stack implements transparency, see . For understanding how nodes discover each other, see .
Receives acknowledgment if Important delivery is enabled
Sends acknowledgment back if Important delivery was requested
The sender waits for the response (either acknowledgment or error) with a timeout
Byte 7: Message type (101 for PID message, 121 for call request, 129 for response, 200 for compressed, etc.)
EDF-encoded message payload
type Order struct {
ID int64
Items []string
}
func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
if err := a.Node().Network().RegisterType(Order{}); err != nil {
return gen.ApplicationSpec{}, err
}
return gen.ApplicationSpec{ /* ... */ }, nil
}
// Later, during message sending:
process.Send(to, Order{ID: 42, Items: []string{"item1"}}) // Uses pre-built encoderpackage orders
type OrderV1 struct { ID int64 } // #github.com/myapp/orders/OrderV1
type OrderV2 struct { ID int64; Priority int } // #github.com/myapp/orders/OrderV2type Order struct {
ID int64
Items []string
}
func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "myapp",
Network: gen.ApplicationNetwork{
RegisterTypes: []any{Order{}, Customer{}, Address{}},
},
Group: []gen.ApplicationMemberSpec{ /* ... */ },
}, nil
}func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
if err := a.Node().Network().RegisterType(Order{}); err != nil {
return gen.ApplicationSpec{}, err
}
return gen.ApplicationSpec{ /* ... */ }, nil
}func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
err := a.Node().Network().RegisterTypes([]any{
Order{},
Customer{},
Address{},
})
if err != nil {
return gen.ApplicationSpec{}, err
}
return gen.ApplicationSpec{ /* ... */ }, nil
}type Order struct {
ID int64 // Exported - part of the contract
items []Item // Unexported - internal state, registration fails
}type Order struct {
ID int64 // part of the contract
items []Item `edf:"-"` // unexported internal state, skipped
cache *LocalCache `edf:"-"` // exported but runtime-only, skipped
}var discount *float64 // nil or value
var prices []*int // slice with nil elements
var cache map[string]*Config // map with nil values
type Order struct {
Priority *int // optional field
}type Address struct {
City string
Street string
}
type Person struct {
Name string
Address Address
}
func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
// Order in the slice doesn't matter. The framework registers
// inner types first and retries until everything resolves.
err := a.Node().Network().RegisterTypes([]any{Person{}, Address{}})
if err != nil {
return gen.ApplicationSpec{}, err
}
return gen.ApplicationSpec{ /* ... */ }, nil
}type Config struct {
public string
private int
}
// Option 1: edf.Marshaler/Unmarshaler (recommended for performance)
func (c Config) MarshalEDF(w io.Writer) error {
buf := make([]byte, 0, 256)
buf = append(buf, c.public...)
buf = binary.BigEndian.AppendUint64(buf, uint64(c.private))
_, err := w.Write(buf)
return err
}
func (c *Config) UnmarshalEDF(b []byte) error {
c.public = string(b[:len(b)-8])
c.private = int(binary.BigEndian.Uint64(b[len(b)-8:]))
return nil
}
// Option 2: encoding.BinaryMarshaler/Unmarshaler (standard interface)
func (c Config) MarshalBinary() ([]byte, error) {
buf := make([]byte, 0, 256)
buf = append(buf, c.public...)
buf = binary.BigEndian.AppendUint64(buf, uint64(c.private))
return buf, nil
}
func (c *Config) UnmarshalBinary(b []byte) error {
c.public = string(b[:len(b)-8])
c.private = int(binary.BigEndian.Uint64(b[len(b)-8:]))
return nil
}var (
ErrInvalidOrder = errors.New("invalid order")
ErrOutOfStock = errors.New("out of stock")
)
func (a *MyApp) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "myapp",
Network: gen.ApplicationNetwork{
RegisterErrors: []error{ErrInvalidOrder, ErrOutOfStock},
},
Group: []gen.ApplicationMemberSpec{ /* ... */ },
}, nil
}return gen.Errorf("user %d: %w", userID, ErrPaymentDeclined)
// on the receiver:
// err.Error() // "user 42: payment declined"
// errors.Is(err, ErrPaymentDeclined) // true
// errors.As(err, &target) // finds a typed cause at any depthvar (
ErrCodeInvalidArgument = errors.New("code:1234") // the group
ErrInvalidArgA = errors.New("invalid arg A") // a member
ErrInvalidArgB = errors.New("invalid arg B") // a member
)
return gen.Errorf("unable to perform request %s: %w %w",
target, ErrInvalidArgB, ErrCodeInvalidArgument)if errors.Is(err, ErrCodeInvalidArgument) {
// anything of this kind, including members added later
}
if errors.Is(err, ErrInvalidArgB) {
// this exact failure
}// Deprecated. Use node.Network().RegisterType / RegisterError / RegisterAtom instead.
edf.RegisterTypeOf(Order{})
edf.RegisterError(ErrInvalidOrder)
edf.RegisterAtom("my_atom")pid, err := node.Spawn(createWorker, gen.ProcessOptions{
Compression: gen.Compression{
Enable: true,
Type: gen.CompressionTypeGZIP,
Level: gen.CompressionDefault,
Threshold: 1024,
},
})process.SetCompression(true)
process.SetCompressionType(gen.CompressionTypeGZIP)
process.SetCompressionLevel(gen.CompressionBestSpeed)
process.SetCompressionThreshold(2048)err := process.SendImportant(remotePID, message)
if err != nil {
// Definitely failed - remote process doesn't exist,
// or mailbox is full, or connection dropped
}SendExit
Always ordered
No KeepNetworkOrder check, always uses sender-derived order byte
SendTerminate
Always unordered
func (w *Worker) Init(args ...any) error {
// This worker processes requests independently,
// ordering doesn't matter
w.SetKeepNetworkOrder(false)
return nil
}Order byte is always 0
Link/Monitor
Always ordered
System operations that must arrive in sequence
Actors fail. They panic, encounter errors, or lose external resources. In traditional systems, you add defensive code: catch exceptions, retry operations, validate state. This spreads failure handling throughout your codebase, mixing recovery logic with business logic.
The actor model takes a different approach: let it crash. When an actor fails, terminate it cleanly and restart it in a known-good state. This requires something watching the actor and managing its lifecycle - a supervisor.
act.Supervisor is an actor that manages child processes. It starts them during initialization, monitors them for failures, and applies restart strategies when they terminate. Supervisors can manage other supervisors, creating hierarchical fault tolerance trees where failures are isolated and recovered automatically.
Like act.Actor, the act.Supervisor struct implements the low-level gen.ProcessBehavior interface and has the embedded gen.Process interface. To create a supervisor, you embed act.Supervisor in your struct and implement the act.SupervisorBehavior interface.
The only mandatory method is Init, which returns a SupervisorSpec describing the children and the restart policy:
All other behavior methods are optional. act.Supervisor provides default implementations:
HandleChildStart and HandleChildTerminate for child lifecycle hooks (only when EnableHandleChild: true).
HandleMessage, HandleCall, HandleEvent for receiving regular messages, synchronous calls, and events while running.
These optional methods are described in detail in the sections below. The full interface is defined in act/supervisor.go if you want to look at the source.
Embed act.Supervisor and implement Init to define the supervision spec:
The supervisor spawns all children during Init (except Simple One For One, which starts with zero children). Each child is connected to the supervisor with a pair of unidirectional links (LinkChild and LinkParent set automatically). If a child terminates, the supervisor receives an exit signal and applies the restart strategy.
Children are started sequentially in declaration order. If any child's spawn fails (the factory's ProcessInit returns an error), the supervisor terminates immediately with that error. This ensures the supervision tree is fully initialized or not at all - no partial states.
The Type field in SupervisorSpec determines what happens when a child fails.
Each child is independent. When one child terminates, only that child is restarted. Other children continue running unaffected.
If worker2 crashes, the supervisor restarts only worker2. worker1 and worker3 keep running. Use this when children are independent - databases, caches, API handlers that don't depend on each other.
Each child runs with a registered name (the Name from the spec). This means only one instance per child spec. To run multiple instances of the same worker, use Simple One For One instead.
Children are tightly coupled. When any child terminates, all children are stopped and restarted together.
If cache crashes, the supervisor stops processor and api (in reverse order if KeepOrder is true, simultaneously otherwise), then restarts all three in declaration order. Use this when children share state or dependencies that can't survive partial failures.
When a child terminates, only children started after it are affected. Children started before it continue running.
If cache crashes, the supervisor stops api, then restarts cache and api in order. database is unaffected. Use this for dependency chains where later children depend on earlier ones, but earlier ones don't depend on later ones.
With KeepOrder: true, children are stopped sequentially (last to first). With KeepOrder: false, they stop simultaneously. Either way, restart happens in declaration order after all affected children have stopped.
All children run the same code, spawned dynamically instead of at supervisor startup.
The supervisor starts with zero children. Call supervisor.StartChild("worker", "custom-args") to spawn instances:
Each instance is independent. They're not registered by name (no SpawnRegister), so you track them by PID. When an instance terminates, only that instance is restarted (if the restart strategy allows). Other instances continue running.
Use Simple One For One for worker pools where you dynamically scale the number of identical workers based on load. The child spec is a template - each StartChild creates a new instance from that template.
A quick decision matrix when you only need to pick the supervisor Type. The next sections explain restart strategies, intensity, per-child overrides, and other knobs that compose with the type you pick.
A larger reference table covering all combinations of type, strategy, per-child overrides, and lifecycle flags is at the end of this document: see .
The Restart.Strategy field on SupervisorRestart sets the default rule for all children. Each child can override it via SupervisorChildRestart.Strategy. See .
Restart only on abnormal termination. If a child returns gen.TerminateReasonNormal or gen.TerminateReasonShutdown, it's not restarted:
Use this for workers that can gracefully stop - maybe they finished their work, or received a shutdown command. Crashes (panics, errors, kills) trigger restarts. Normal termination doesn't.
Never restart, regardless of termination reason:
The child runs once. If it terminates (normal or crash), it stays terminated. Use this for initialization tasks or processes that shouldn't be restarted automatically.
Always restart, regardless of termination reason:
Even gen.TerminateReasonNormal triggers restart. Use this for critical processes that must always be running - maybe a health monitor or connection manager that should never stop.
With Permanent strategy, DisableAutoShutdown is ignored, and the Significant flag has no effect - every child termination triggers restart.
Restarts aren't free. If a child crashes repeatedly, restarting it repeatedly just wastes resources. The Intensity and Period options limit restart frequency:
The supervisor tracks restart timestamps (in milliseconds). When a child terminates and needs restart, the supervisor checks: have there been more than Intensity restarts in the last Period seconds? If yes, the restart intensity is exceeded.
When the intensity is exceeded the supervisor stops all running children and terminates itself:
Each child receives gen.ErrExceeded as its exit reason.
The supervisor itself exits with *gen.Error{Msg: "supervisor restart intensity exceeded (max 5 in 5s): ...", Wrapped: [gen.ErrExceeded, originalChildReason]}.
A parent supervisor or monitor can call errors.Is(reason, gen.ErrExceeded) to detect the cause. The original child reason is the second wrapped cause: match it with errors.Is
Old restarts outside the period window are discarded from tracking. This is a sliding window: if your child crashes 5 times in 10 seconds, then runs stable for 11 seconds, then crashes again, the counter resets. It is 1 restart in the window, not 6 total.
Default values are Intensity: 5 and Period: 5 if you don't specify them.
The supervisor-level counter is shared across all children that don't opt in to a per-child counter. To give an individual child its own restart budget, see .
Most supervisors only need the supervisor-level Restart. But when you need fine-grained control (one child should be allowed to fail without taking down the rest, different children need different restart semantics, or you want a Simple One For One pool where a misbehaving instance doesn't kill the pool), each child can override the defaults via the optional Restart field on SupervisorChildSpec:
Three independent axes, all opt-in. A zero-value SupervisorChildRestart means "inherit everything from the supervisor", so existing specs continue to work unchanged.
SupervisorStrategyInherit is the zero-value sentinel for the Strategy field. At the child level it means "use the supervisor's Strategy", which is exactly the behavior of any child spec without an explicit per-child Restart. (At the supervisor level, Inherit is normalized to SupervisorStrategyTransient on init.)
To override, mix and match restart strategies in one supervisor:
core inherits Permanent and is always restarted. diagnostics is Temporary and runs once. logger is Transient and stops only on a clean exit.
For All For One and Rest For One supervisors, the per-child Strategy controls whether this child's termination is treated as a trigger for the group restart:
A Permanent child terminating (any reason) triggers the group restart.
A Transient child terminating abnormally triggers it. A normal exit removes the child without triggering anything.
A Temporary child terminating just removes the child. Siblings keep running.
This matches OTP semantics: in a coupled group, you can mark some children as "coupled" (Permanent / Transient) and others as "best-effort" (Temporary).
Setting Intensity > 0 gives a child its own restart counter, separate from the supervisor's global counter:
For One For One the counter is per-spec: every restart of this child (regardless of how many times other children flap) counts only against this child's budget. For Simple One For One the counter is per-instance, where a logical instance is the lifetime of one StartChild call: its args, its restart history, and the chain of PIDs across restarts. Each StartChild invocation creates a new logical instance with its own counter; the counter survives across restarts of that same logical instance, even though the PID changes on each restart.
For All For One and Rest For One a per-child Intensity is rejected at supervisor init with act.ErrSupervisorInvalidSpec. Group-restart semantics make per-child thresholds meaningless: when one child fails, the supervisor restarts the whole group, so charging a per-child counter is undefined.
Children with Intensity == 0 (the default) keep using the supervisor's global counter, exactly as before. You can mix freely: some children with their own counters, others sharing the global one, in the same supervisor.
When a per-child counter overflows, the default reaction is the same as for the global counter: terminate the supervisor. Sometimes you want the opposite. A noisy non-critical child should be quietly disabled while its siblings keep running.
Set OnExceed: act.OnExceedDisable:
Behavior on overflow:
For One For One: the child spec is marked disabled and the supervisor stays alive while any other child is still running. Other children are unaffected, and EnableChild re-enables the disabled one later, clearing its local counter. If disabling it leaves no running children, auto-shutdown applies and the supervisor terminates with the intensity-exceeded reason after all - so a One For One supervisor with a single child and OnExceedDisable dies exactly where it looks like it would survive. Set DisableAutoShutdown if it should wait for an EnableChild instead.
For Simple One For One: the offending instance is dropped from the supervisor. The spec stays available for new
OnExceedDisable requires Intensity > 0. Setting OnExceedDisable without a per-child counter is rejected at init (there is no counter to overflow).
The default value OnExceedTerminateSupervisor mirrors the supervisor-level behavior. When a per-child counter with this setting overflows, the supervisor terminates with the same wrap as the global-counter overflow described above: *gen.Error{Msg: "supervisor restart intensity exceeded (max 5 in 5s): ...", Wrapped: [gen.ErrExceeded, originalChildReason]}.
The supervisor rejects the following at Init with act.ErrSupervisorInvalidSpec:
Intensity > 0 for All For One or Rest For One.
OnExceed: OnExceedDisable without Intensity > 0.
Period > 0 without Intensity > 0
Errors are wrapped, so errors.Is(err, act.ErrSupervisorInvalidSpec) matches.
Adding SupervisorChildRestart is purely additive. A zero-value Restart field means "inherit everything", which is exactly the behavior every existing spec relies on:
Strategy inherits from the supervisor.
The supervisor's global counter is used.
On global overflow, the supervisor terminates as it always did.
Setting per-child Restart on one child does not change behavior for any other child.
A per-child counter does not protect that child from a global overflow. If one child without a per-child counter floods the supervisor's global counter past Intensity, the supervisor terminates the whole subtree. Children configured with OnExceedDisable are also terminated as part of that shutdown.
For full isolation, give every child its own Intensity, or set the supervisor-level Intensity high enough to absorb any expected noise.
Three patterns cover most cases where one child's failure should not bring down the supervisor.
You have a supervisor where most children are critical, but one is allowed to fail. Telemetry, optional caches, background metrics collectors are typical examples.
database and api share the supervisor's global counter. Five failures of either one in 5 seconds kills the subtree, and a parent supervisor will rebuild it.
telemetry runs on its own counter (100 restarts in 60 seconds is a high tolerance, on purpose). When the telemetry pipeline degrades and starts crashing repeatedly, only telemetry is dropped. The rest of the application keeps serving requests.
To bring telemetry back later (after fixing the underlying issue, or after a config flag change):
You have a Simple One For One pool where each instance handles a different task. A poison-pill input that crashes one worker should not take down all the others.
Each instance keeps its own counter, linked to its args. The counter survives across restarts of the same logical instance: if taskA panics once and is restarted, the counter is at 1; if it panics again, the counter is at 2; and so on.
If taskA crashes 5 times in 10 seconds, only that instance is dropped. taskB is untouched, and StartChild("worker", taskC) will spawn a fresh instance with a fresh counter at any time.
This is the canonical pattern for per-request actors, per-connection handlers, per-task workers. One bad input should never cascade into a full pool wipeout.
You have a supervisor where different children deserve different rules. Some are critical and must always run, some can finish their work cleanly, some are one-shot.
watchdog inherits Permanent, so it is always restarted. batch is Transient, so a normal exit removes it (the work is done) but a crash restarts it. init_task is Temporary, so it runs once at supervisor startup and then stays gone.
For All For One or Rest For One supervisors the same per-child Strategy override controls triggering: a Temporary child can fail without triggering a group restart, while a Permanent child's failure always does.
By default, when a process restarts after a failure, its mailbox is reset to empty. Any messages that arrived before the failure but were not yet processed are lost. For some children this is fine. For others (workers mid-task, request handlers with queued work) losing those messages means losing work.
The framework provides an opt-in mechanism to carry a dying process's mailbox over to its restarted incarnation, so the new instance picks up exactly where the old one left off (minus the one message that triggered the failure, which is treated as already consumed).
Set Options.PreserveMailbox: true on the child spec:
That single flag is the entire user-facing knob. The framework handles the rest: when the worker terminates abnormally, the runtime captures its mailbox into a *gen.Error exit reason. The supervisor extracts the mailbox from that reason and hands it to the next spawn. The new incarnation begins life with the surviving messages already in its queues, in their original priority order.
The runtime captures the mailbox on any abnormal termination:
panic in a callback,
callback returning an arbitrary error,
forced Kill,
exit signal cascade from a linked process that died abnormally.
Normal exits (gen.TerminateReasonNormal and gen.TerminateReasonShutdown) do not trigger capture. A clean shutdown means the actor explicitly decided to stop, and reusing its mailbox on the next incarnation would contradict that decision.
Only One For One and Simple One For One. All For One and Rest For One use group-restart semantics: when one child fails, every sibling is torn down and rebuilt together. Preserving the mailbox of one specific child while wiping the siblings' state is contradictory. The supervisor rejects PreserveMailbox: true on All For One / Rest For One children at init with act.ErrSupervisorInvalidSpec.
The triggering message is not replayed. The message the actor was processing when it failed has already been popped from the queue. It is considered consumed (with an error). Replaying it would create restart loops on poison-pill messages: each retry would fail again until restart intensity intervenes. The framework deliberately drops the triggering message and resumes from the next one in queue.
Each StartChild invocation creates a worker instance with its own restart counter. When a worker panics, its mailbox is captured and handed to the restart. The new incarnation continues processing the queued tasks. If the same logical instance keeps panicking past its budget (5 failures in 10 seconds), OnExceedDisable drops just that instance while the rest of the pool keeps serving. Without OnExceedDisable, the supervisor itself would terminate.
A live mailbox cannot cross the network. The Mailbox fields on gen.Error and gen.ProcessOptions are excluded from EDF wire encoding via the edf:"-" tag. On remote spawn or remote exit signals, those fields are zero-valued (nil) on the receiving side. Mailbox preservation is a same-node feature.
In One For One, All For One and Rest For One supervisors, the Significant flag marks children whose termination can trigger supervisor shutdown:
With SupervisorStrategyTransient:
Significant child terminates normally → supervisor stops all children and terminates
Significant child crashes → restart strategy applies
Non-significant child → restart strategy applies regardless of termination reason
With SupervisorStrategyTemporary:
Significant child terminates (any reason) → supervisor stops all children and terminates
Non-significant child → no restart, child stays terminated
With SupervisorStrategyPermanent:
Significant flag is ignored
All terminations trigger restart
Only Simple One For One ignores Significant entirely - its children are anonymous instances of one spec, so there is no particular child whose ending means anything. One For One honours the flag exactly as the two rules above describe.
Use significant children when a specific child's clean termination means "mission accomplished, shut down the subtree." Example: a batch processor that finishes its work and terminates normally should stop the entire supervision tree, not get restarted.
By default, if all children terminate normally (not crashes) and none are significant, the supervisor stops itself with gen.TerminateReasonNormal. This is auto shutdown.
Enable DisableAutoShutdown to keep the supervisor running even with zero children:
Auto shutdown is ignored for Simple One For One supervisors (they're designed for dynamic children) and ignored when using Permanent strategy.
Use auto shutdown when your supervisor's purpose is managing those specific children. When they're all gone, the supervisor has no purpose. Disable it when the supervisor manages dynamically added children or should stay alive to accept management commands.
For All For One and Rest For One, the KeepOrder flag controls how children are stopped:
With KeepOrder: true:
Children stop one at a time, last to first
Supervisor waits for each child to fully terminate before stopping the next
Slow but orderly - useful when children have shutdown dependencies
With KeepOrder: false (default):
All affected children receive SendExit simultaneously
They terminate in parallel
Fast but unordered - use when children can shut down independently
After stopping (either way), children restart sequentially in declaration order. KeepOrder only affects stopping, not starting.
For One For One and Simple One For One, KeepOrder is ignored (only one child is affected).
Supervisors provide methods for runtime adjustments:
Critical: These methods fail with act.ErrSupervisorStrategyActive if called while the supervisor is executing a restart strategy (stopping children, waiting for their exit signals, or starting replacements). You must wait for the strategy to finish before issuing management calls.
There is a second gate in front of that one, and it catches the more common mistake: each of these methods first requires the supervisor to be in the Running state, and returns gen.ErrNotAllowed otherwise. Init runs in the Init state, so calling StartChild or AddChild from your own Init fails there. Declare the children in the returned spec, or post a message to yourself and add them from the handler that receives it.
While a restart strategy is running, the supervisor processes only the Urgent queue (where exit signals arrive) and ignores System and Main queues. This guarantees exit signals are handled promptly without interference from management commands or regular messages.
For Simple One For One supervisors, StartChild with args stores those args for that specific child instance. When that instance restarts (due to crash, kill, etc.), it uses the stored args, not the template args from the spec. For other supervisor types (One For One, All For One, Rest For One), StartChild with args updates the spec's args for future restarts.
Enable EnableHandleChild: true to receive notifications when children start or stop:
These callbacks run after the restart strategy completes. For example:
Child crashes
Supervisor applies restart strategy (stops affected children if needed)
Supervisor starts replacement children
Then HandleChildTerminate is called for the terminated child
The callbacks are invoked as regular messages sent by the supervisor to itself. They arrive in the Main queue, so they're processed after the restart logic (which happens in the exit signal handler).
If HandleChildStart or HandleChildTerminate returns an error, the supervisor terminates with that error. Use these callbacks for integration with external systems, not for restart decisions - restart logic is handled by the supervisor type and strategy.
Supervisors are actors. They have mailboxes, handle messages, and can communicate with other processes:
This lets you build management APIs: query supervisor state, scale children dynamically, reconfigure at runtime. The supervisor processes these messages between handling exit signals.
Supervisors provide runtime inspection via the HandleInspect method, which is automatically integrated with the Observer monitoring tool. When you call gen.Process.Inspect() on a supervisor, it returns detailed metrics about its current state:
One For One / All For One / Rest For One:
ergo:type: Supervisor type ("One For One", "All For One", "Rest For One")
ergo:strategy: Restart strategy (Transient, Temporary, Permanent)
ergo:intensity: Maximum restart count within period
Simple One For One:
ergo:type: "Simple One For One"
ergo:strategy: Restart strategy
ergo:intensity: Maximum restart count within period
Restart history (all supervisor types):
ergo:history:count: Number of restart events currently kept in the ring buffer
ergo:history:<N>:time: RFC3339Nano timestamp of restart event N (oldest at index 0)
ergo:history:<N>:child: Spec name of the child that triggered this restart
All of these keys use the reserved ergo: prefix. A HandleInspect you implement is merged on top of them, so your fields are added beside these rather than replacing the set - and one of these is overridden only if you name it with the prefix.
The history captures up to 50 recent restart decisions and is the fastest path to diagnose "why is this subtree flapping" without parsing logs. For All For One / Rest For One supervisors only the triggering child is recorded, not the cascading sibling kills.
The Observer UI displays this information in real-time, letting you monitor supervision trees, track restart patterns, and identify failing components. You can also query this data programmatically:
Both methods only work for local supervisors (same node). This integration makes it easy to diagnose issues in production: check restart counts to identify unstable processes, verify child counts match expected scaling, monitor which instances have custom configurations.
Understanding restart intensity is critical for reliable systems. Here's exactly how it works:
The supervisor maintains a list of restart timestamps in milliseconds. When a child terminates and restart is needed:
Append current timestamp to the list.
Remove timestamps older than Period seconds.
If list length > Intensity, intensity is exceeded.
When a per-child counter is configured, the same algorithm runs against the child's own restart history using the child's own Intensity and Period. With OnExceed: OnExceedDisable, step 4 changes: instead of terminating the supervisor, the child is disabled (One For One) or the offending instance is dropped (Simple One For One), and the supervisor stays alive. With OnExceedTerminateSupervisor (the default), step 4 produces the same *gen.Error wrap as the global path.
Example with Intensity: 3, Period: 5:
But if the child runs stable between crashes:
The sliding window means intermittent failures don't accumulate. Only rapid repeated failures exceed intensity.
When a supervisor terminates (receives exit signal, calls terminate from HandleMessage, or crashes), it stops all children first:
Send gen.TerminateReasonShutdown via SendExit to all running children
Wait for all children to terminate
Call Terminate callback
With KeepOrder: true (All For One / Rest For One), children stop sequentially. With KeepOrder: false, they stop in parallel. Either way, the supervisor waits for all to finish before terminating itself.
If a non-child process sends the supervisor an exit signal (via Link or SendExit), the supervisor initiates shutdown. This is how parent supervisors stop child supervisors - send an exit signal, and the entire subtree shuts down cleanly.
Simple One For One supervisors start with empty children and spawn them on demand:
Start instances with StartChild:
Each call spawns a new worker. The args passed to StartChild are stored for that specific instance. When the restart strategy triggers (child crashes, exceeds intensity, etc.), the child restarts with the same args it was originally started with, not the template args from the spec. This ensures each worker instance maintains its configuration across restarts.
Workers are not registered by name (no SpawnRegister), and StartChild returns only an error - no PID. You learn the instance PIDs from supervisor.Children(), whose SupervisorChild entries carry Spec, Name, PID, Significant and Disabled, or from the HandleChildStart callback when EnableHandleChild
Disabling a child spec stops all running instances with that spec name:
Simple One For One ignores DisableAutoShutdown - the supervisor never auto-shuts down, even with zero children. It's designed for dynamic workloads where zero children is a valid state.
Default Strategy is Transient. The supervisor-level Strategy zero value is SupervisorStrategyInherit, which is normalized to SupervisorStrategyTransient on init. Children with no explicit Restart field inherit Transient. To change the default for the whole supervisor, set Strategy explicitly on SupervisorRestart.
Set restart intensity carefully. Too low and transient failures kill your supervisor. Too high and crash loops consume resources. Start with defaults (Intensity: 5, Period: 5) and tune based on observed behavior.
Use Significant sparingly. Marking a child significant couples its lifecycle to the entire supervision tree. This is powerful but reduces isolation. Prefer non-significant children and handle critical failures at a higher supervision level.
Don't call management methods during restart. StartChild, AddChild, EnableChild, DisableChild fail with ErrSupervisorStrategyActive if the supervisor is mid-restart. Wait for the restart to complete (check via Inspect or wait for HandleChildStart callback).
Disable auto shutdown for dynamic supervisors. If your supervisor uses AddChild to add children at runtime, enable DisableAutoShutdown. Otherwise it terminates once all its children have stopped. It cannot start empty and wait: Init rejects an empty Children list with "children list can not be empty", so a dynamic supervisor still declares at least one child.
Use HandleChildStart for integration, not validation. By the time HandleChildStart is called, the child is already spawned and linked. Returning an error terminates the supervisor, but doesn't prevent the child from running. Use child's Init for validation instead.
KeepOrder is only for stopping. Children always start sequentially in declaration order. KeepOrder controls only the stopping phase of All For One and Rest For One restarts.
Simple One For One args are persistent per instance. Args passed to StartChild are stored and used for that specific instance across all restarts. If you start a worker with StartChild("worker", "config-A") and it crashes, the restarted instance receives "config-A" again, not the template args from the child spec. This persistence ensures each worker maintains its identity and configuration through failures. If you need different args for a restart, you must manually stop the old instance and start a new one with different args.
Per-child counter does not protect from a global overflow. A child with OnExceedDisable is still terminated as a side effect when another child overflows the supervisor's global counter. If you need a child to truly survive other children's failures, give every child a per-child Intensity, or raise the supervisor-level Intensity enough to absorb the noise.
OnExceedDisable requires Intensity > 0. Setting OnExceed without a per-child counter is rejected at init. The reasoning: there is no per-child counter to overflow, and applying Disable on the global counter would be ambiguous (which child should be disabled?).
Per-child Intensity is rejected for All For One and Rest For One. Group-restart strategies have no use for per-child thresholds: when one child fails, the supervisor restarts the whole group, so charging a per-child counter has no defined meaning.
Use errors.Is and errors.As to inspect failures. When a supervisor terminates due to a restart-intensity overflow, its exit reason is *gen.Error{Msg: "supervisor restart intensity exceeded (max 5 in 5s): ...", Wrapped: [gen.ErrExceeded, originalChildReason]}. A parent supervisor or monitor can match the structural cause with errors.Is(reason, gen.ErrExceeded) and the underlying child failure with errors.Is or errors.As on the same reason - both visit every wrapped cause, so no manual traversal is needed.
By the time you reach this section every term in the table below has been introduced. Use it as a quick reference: pick the row that matches the behavior you want and apply the combination on the right.
Most rows are composable in a single supervisor: different children can have different per-child Restart, per-child Restart can combine with Significant, and DisableAutoShutdown is orthogonal to everything above. Only the per-child Intensity field is exclusive to SupervisorTypeOneForOne and SupervisorTypeSimpleOneForOne.
Data Types and Interfaces Used in Ergo Framework
Ergo Framework uses several specialized types for identifying and addressing processes, nodes, and other entities in the system. Understanding these types is essential for working with the framework.
gen.Atom is a specialized string used for names - node names, process names, event names. While technically just a string, treating it as a distinct type allows the framework to optimize how these names are handled in the network stack.
Atoms appear in single quotes when printed:
The network stack caches atoms and maps them to numeric IDs to reduce bandwidth when the same names appear repeatedly in messages.
A gen.PID uniquely identifies a process. It contains the node name where the process lives, a unique sequential ID, and a creation timestamp. The creation timestamp changes when a node restarts, allowing you to detect if you're talking to a reincarnation of a node rather than the original.
gen.PID
HandleInspect for diagnostic queries.
Terminate for cleanup on supervisor exit.
Dynamically created identical workers (one template, many instances)
SupervisorTypeSimpleOneForOne
errors.AsWrappedStartChildUnknown Strategy value.
Init from scratch and then starts pulling surviving messages.Then HandleChildStart is called for the replacement
ergo:period: Time window in seconds for restart intensity
ergo:keep_order: Whether children stop sequentially (All/Rest For One only)
ergo:auto_shutdown: Whether supervisor stops when all children terminate
ergo:restarts_count: Number of supervisor-level restart timestamps currently tracked
ergo:children_total: Total child specs defined
ergo:children_running: Currently running children
ergo:children_disabled: Disabled children that won't restart
ergo:child:<name>:restarts: Per-child restart count, only present for children with Intensity > 0 in their SupervisorChildRestart
ergo:period: Time window in secondsergo:restarts_count: Number of supervisor-level restart timestamps tracked
ergo:specs_total: Total child spec templates
ergo:specs_disabled: Disabled specs
ergo:instances_total: Total running instances across all specs
ergo:child:<name>: Number of running instances for that child spec
ergo:child:<name>:args: Number of instances with custom args for that child spec
ergo:child:<name>:restarts: Aggregated per-instance restart count for that spec, only present when the spec has Intensity > 0 in its SupervisorChildRestart
ergo:history:<N>:reason: Error() string of the termination reason
gen.ErrExceeded*gen.Error{Msg: "supervisor restart intensity exceeded (max 5 in 5s): ...", Wrapped: [gen.ErrExceeded, originalChildReason]}gen.ErrExceedederrors.Iserrors.Iserrors.AsIf not exceeded: proceed with restart.
Remove supervisor from node
Dynamic pool with one shared restart budget.
Type: SupervisorTypeSimpleOneForOne.
Dynamic pool of one-shot workers (fire-and-forget).
Type: SupervisorTypeSimpleOneForOne and supervisor Strategy: SupervisorStrategyTemporary.
One child is allowed to degrade and stay disabled while siblings keep running.
Type: SupervisorTypeOneForOne plus per-child Restart: SupervisorChildRestart{Intensity, Period, OnExceed: OnExceedDisable}. Re-enable later with EnableChild.
Pool where one bad instance is dropped while the pool keeps serving.
Type: SupervisorTypeSimpleOneForOne plus per-child Restart: SupervisorChildRestart{Intensity, Period, OnExceed: OnExceedDisable}.
One child has its own restart budget but overflow still terminates the supervisor.
Type: SupervisorTypeOneForOne or SupervisorTypeSimpleOneForOne plus per-child Restart: SupervisorChildRestart{Intensity, Period}. Default OnExceed terminates the supervisor.
Child that runs once and stays gone.
Per-child Restart: SupervisorChildRestart{Strategy: SupervisorStrategyTemporary}.
Child that always restarts, even on Normal exit.
Per-child Restart: SupervisorChildRestart{Strategy: SupervisorStrategyPermanent}.
All For One or Rest For One child whose abnormal exit must trigger a group restart.
Per-child Strategy: SupervisorStrategyTransient (default) or SupervisorStrategyPermanent.
All For One or Rest For One child whose death must not trigger a group restart.
Per-child Restart: SupervisorChildRestart{Strategy: SupervisorStrategyTemporary}.
Clean exit of one child ends the whole subtree.
Type: SupervisorTypeAllForOne or SupervisorTypeRestForOne, supervisor Strategy: SupervisorStrategyTransient, per-child Significant: true.
Supervisor stays alive with zero children (used to manage children added at runtime).
DisableAutoShutdown: true.
Worker resumes its queued messages after a panic restart.
Type: SupervisorTypeOneForOne or SupervisorTypeSimpleOneForOne, child Options: gen.ProcessOptions{PreserveMailbox: true}.
Independent of each other (failure of one is unrelated to others)
SupervisorTypeOneForOne
Tightly coupled (any failure means restart everyone)
SupervisorTypeAllForOne
Arranged in a dependency chain (later children depend on earlier ones)
Independent children. Supervisor dies if any one flaps too much.
Type: SupervisorTypeOneForOne and supervisor-level Restart. No per-child override.
All children coupled. Any failure restarts the whole group.
Type: SupervisorTypeAllForOne and supervisor-level Restart.
Dependency chain. Failure restarts this child and every child after it.
SupervisorTypeRestForOne
Type: SupervisorTypeRestForOne and supervisor-level Restart.
Init(args ...any) (SupervisorSpec, error)type AppSupervisor struct {
act.Supervisor
}
func (s *AppSupervisor) Init(args ...any) (act.SupervisorSpec, error) {
return act.SupervisorSpec{
Type: act.SupervisorTypeOneForOne,
Children: []act.SupervisorChildSpec{
{
Name: "database",
Factory: createDBWorker,
Args: []any{"postgres://..."},
},
{
Name: "api",
Factory: createAPIServer,
Args: []any{8080},
},
},
Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyTransient,
Intensity: 5,
Period: 5,
},
}, nil
}
func createSupervisorFactory() gen.ProcessBehavior {
return &AppSupervisor{}
}
// Spawn the supervisor
pid, err := node.Spawn(createSupervisorFactory, gen.ProcessOptions{})Type: act.SupervisorTypeOneForOne,
Children: []act.SupervisorChildSpec{
{Name: "worker1", Factory: createWorker},
{Name: "worker2", Factory: createWorker},
{Name: "worker3", Factory: createWorker},
},Type: act.SupervisorTypeAllForOne,
Children: []act.SupervisorChildSpec{
{Name: "cache", Factory: createCache},
{Name: "processor", Factory: createProcessor}, // Depends on cache
{Name: "api", Factory: createAPI}, // Depends on both
},Type: act.SupervisorTypeRestForOne,
Children: []act.SupervisorChildSpec{
{Name: "database", Factory: createDB}, // Independent
{Name: "cache", Factory: createCache}, // Depends on database
{Name: "api", Factory: createAPI}, // Depends on cache
},Type: act.SupervisorTypeSimpleOneForOne,
Children: []act.SupervisorChildSpec{
{
Name: "worker",
Factory: createWorker,
Args: []any{"default-config"},
},
},// Start 5 worker instances
for i := 0; i < 5; i++ {
supervisor.StartChild("worker", fmt.Sprintf("worker-%d", i))
}Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyTransient, // Default
}Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyTemporary,
}Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyPermanent,
}Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyTransient,
Intensity: 5, // Maximum 5 restarts
Period: 10, // Within 10 seconds
}type SupervisorChildRestart struct {
Strategy SupervisorStrategy
Intensity uint16
Period uint16
OnExceed OnExceed
}SupervisorSpec{
Type: act.SupervisorTypeOneForOne,
Restart: act.SupervisorRestart{Strategy: act.SupervisorStrategyPermanent},
Children: []act.SupervisorChildSpec{
{Name: "core", Factory: createCore},
{
Name: "diagnostics",
Factory: createDiagnostics,
Restart: act.SupervisorChildRestart{
Strategy: act.SupervisorStrategyTemporary,
},
},
{
Name: "logger",
Factory: createLogger,
Restart: act.SupervisorChildRestart{
Strategy: act.SupervisorStrategyTransient,
},
},
},
}Restart: act.SupervisorChildRestart{
Intensity: 5,
Period: 60,
}Restart: act.SupervisorChildRestart{
Intensity: 5,
Period: 60,
OnExceed: act.OnExceedDisable,
}SupervisorSpec{
Type: act.SupervisorTypeOneForOne,
Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyPermanent,
Intensity: 5,
Period: 5,
},
Children: []act.SupervisorChildSpec{
{Name: "database", Factory: createDB},
{Name: "api", Factory: createAPI},
{
Name: "telemetry",
Factory: createTelemetry,
Restart: act.SupervisorChildRestart{
Intensity: 100,
Period: 60,
OnExceed: act.OnExceedDisable,
},
},
},
}sup.EnableChild("telemetry") // clears the local counter, spawns a fresh instanceSupervisorSpec{
Type: act.SupervisorTypeSimpleOneForOne,
Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyTransient,
},
Children: []act.SupervisorChildSpec{{
Name: "worker",
Factory: createWorker,
Restart: act.SupervisorChildRestart{
Intensity: 5,
Period: 10,
OnExceed: act.OnExceedDisable,
},
}},
}sup.StartChild("worker", taskA) // first instance, args = taskA
sup.StartChild("worker", taskB) // second instance, args = taskBSupervisorSpec{
Type: act.SupervisorTypeOneForOne,
Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyPermanent,
},
Children: []act.SupervisorChildSpec{
{Name: "watchdog", Factory: createWatchdog},
{
Name: "batch",
Factory: createBatch,
Restart: act.SupervisorChildRestart{
Strategy: act.SupervisorStrategyTransient,
},
},
{
Name: "init_task",
Factory: createInitTask,
Restart: act.SupervisorChildRestart{
Strategy: act.SupervisorStrategyTemporary,
},
},
},
}SupervisorChildSpec{
Name: "worker",
Factory: createWorker,
Options: gen.ProcessOptions{
PreserveMailbox: true,
},
}SupervisorSpec{
Type: act.SupervisorTypeSimpleOneForOne,
Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyTransient,
},
Children: []act.SupervisorChildSpec{{
Name: "task_worker",
Factory: createTaskWorker,
Options: gen.ProcessOptions{
PreserveMailbox: true,
},
Restart: act.SupervisorChildRestart{
Intensity: 5,
Period: 10,
OnExceed: act.OnExceedDisable,
},
}},
}Children: []act.SupervisorChildSpec{
{
Name: "critical_service",
Factory: createCriticalService,
Significant: true, // If this stops cleanly, supervisor stops
},
{
Name: "helper",
Factory: createHelper,
// Significant: false (default)
},
},DisableAutoShutdown: false, // Default - supervisor stops when children stopDisableAutoShutdown: true, // Supervisor stays alive with zero childrenRestart: act.SupervisorRestart{
KeepOrder: true, // Stop sequentially in reverse order
}// Start a child from the spec (if not already running)
err := supervisor.StartChild("worker")
// Start with different args (overrides spec)
err := supervisor.StartChild("worker", "new-config")
// Add a new child spec and start it
err := supervisor.AddChild(act.SupervisorChildSpec{
Name: "new_worker",
Factory: createWorker,
})
// Disable a child (stops it, won't restart on crash)
err := supervisor.DisableChild("worker")
// Re-enable a disabled child (starts it again)
err := supervisor.EnableChild("worker")
// Get list of children
children := supervisor.Children()
for _, child := range children {
fmt.Printf("Spec: %s, PID: %s, Disabled: %v\n",
child.Spec, child.PID, child.Disabled)
}func (s *AppSupervisor) Init(args ...any) (act.SupervisorSpec, error) {
return act.SupervisorSpec{
EnableHandleChild: true,
// ... rest of spec
}, nil
}
func (s *AppSupervisor) HandleChildStart(name gen.Atom, pid gen.PID) error {
s.Log().Info("child %s started with PID %s", name, pid)
// Maybe register in service discovery, send init message
return nil
}
func (s *AppSupervisor) HandleChildTerminate(name gen.Atom, pid gen.PID, reason error) error {
s.Log().Info("child %s (PID %s) terminated: %s", name, pid, reason)
// Maybe deregister from service discovery, clean up resources
return nil
}func (s *AppSupervisor) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case ScaleCommand:
if msg.Up {
s.AddWorkers(msg.Count)
} else {
s.RemoveWorkers(msg.Count)
}
case HealthCheckRequest:
children := s.Children()
s.Send(from, HealthResponse{
Running: len(children),
Healthy: s.countHealthy(children),
})
}
return nil
}
func (s *AppSupervisor) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
switch request.(type) {
case GetChildrenRequest:
return s.Children(), nil
}
return nil, nil
}// From within a process context
info, err := process.Inspect(supervisorPID)
// Directly from the node
info, err := node.Inspect(supervisorPID)
// Returns map[string]string with metrics aboveTime 0s: Child crashes → restart (count: 1)
Time 1s: Child crashes → restart (count: 2)
Time 2s: Child crashes → restart (count: 3)
Time 3s: Child crashes → EXCEEDED (count: 4 within 5s window)
→ Stop all children, supervisor terminatesTime 0s: Child crashes → restart (count: 1)
Time 6s: Child crashes → restart (count: 1, previous outside window)
Time 12s: Child crashes → restart (count: 1, previous outside window)Type: act.SupervisorTypeSimpleOneForOne,
Children: []act.SupervisorChildSpec{
{
Name: "worker", // Template name
Factory: createWorker,
Args: []any{"default-config"},
},
},// Start 10 workers with different args
for i := 0; i < 10; i++ {
supervisor.StartChild("worker", fmt.Sprintf("worker-%d", i))
}// Stops all "worker" instances
supervisor.DisableChild("worker")The leading hash is a CRC32 of the node name, computed with the framework's own polynomial table rather than a standard one, so node@localhost always prints as 2C323E75. It keeps the printed form compact while staying distinct per node. The two numbers after it are the halves of the single ID field - high 32 bits, then low - so a small ID prints its high half as 0. Creation is not in the printed form at all.
A gen.ProcessID identifies a process by its registered name rather than gen.PID. This is useful when you need to address a process but don't know its gen.PID, or when the gen.PID might change across restarts but the name remains constant.
The name is appended as-is here. Only gen.Atom quotes itself when printed.
gen.Ref values are unique identifiers generated by nodes. They're used for correlating requests and responses in synchronous calls, and as tokens when registering events.
A gen.Ref is guaranteed unique within a node for its lifetime. The structure includes the node name, creation time, and a unique ID array.
References can also embed deadlines (stored in ID[2]) for timeout tracking. Recipients can check ref.IsAlive() to see if a request is still valid.
gen.Alias is like a temporary gen.PID. Processes create aliases for additional addressability without registering names. Meta processes use aliases as their primary identifier.
Aliases use the same structure as references but print with a different prefix:
gen.Event values represent named message streams that processes can subscribe to. A gen.Event identifier consists of a name and the node where it's registered.
Environment variable names in Ergo are case-insensitive. The gen.Env type ensures this by converting to uppercase.
This allows processes to inherit environment variables from parents, leaders, and the node, with consistent naming regardless of how they're specified.
gen.Error is a wrapping error type used as a process exit reason. It carries a message, the causes it wraps - whose identity is observable through errors.Is and survives a trip across the network - and an optional captured mailbox.
Msg is the message text. gen.Errorf fills it with fmt.Errorf output, which means the causes' own text ends up inside it as well.
Wrapped holds the errors the %w substitutions referred to, in argument order. Reading them is described below.
Mailbox carries the captured mailbox of a panicked process for replay on supervisor restart. Excluded from network encoding via edf:"-", so it never crosses the wire.
Most user code never constructs *gen.Error directly. Use gen.Errorf instead, which mirrors fmt.Errorf and produces a *gen.Error with the wrap chain preserved:
gen.Errorf composes a value at the point of failure. It is not a way to declare a sentinel: package-level markers must be errors.New values, because a *gen.Error cannot be registered for the wire and loses its identity across a node hop. See Encoding Errors.
Wrapped is what tells apart the three basic shapes gen.Errorf can produce. A single cause is the common one:
Several causes at one level state independent facts about the same failure. This is a set rather than a chain: Wrapped[0] carries no special meaning, and the order follows the order of the arguments, not the position of the verbs in the format string:
Nesting happens when a *gen.Error is itself wrapped. Every level renders the inner text into its own Msg, so the text accumulates as the chain grows:
The shapes mix freely - a nested error may carry several causes of its own, at any depth - and the way to read them does not depend on which one you got. To ask whether a failure is of a given kind, use errors.Is. To pull out a value of a known type, use errors.As. Both visit every cause depth-first:
errors.Unwrap is not on that list. It calls only the Unwrap() error form, and gen.Error implements Unwrap() []error in order to carry several causes, so errors.Unwrap returns nil for every shape above - including a single cause, where nothing at the call site hints at the multi-error form. errors.Join behaves the same way for the same reason.
When the cause objects themselves are needed, assert to *gen.Error and read Wrapped, checking its length rather than indexing it straight away. gen.Errorf leaves the list empty whenever a %w argument was nil or was not an error, and in both cases the message still reads plausibly:
One thing not to do is recover a single level's own text by subtracting an inner error's text from the outer Msg. The wrap chain preserves identity, not the boundaries between the pieces of the message, and the format string those pieces were glued with is not part of any contract. When a receiver needs a value separately from the message, that value has to travel as a value: a registered marker if it is one of an enumerable set, a typed field beside the error for anything else.
The framework uses gen.Error in two places today:
Restart intensity exceeded. The supervisor's exit reason on overflow is built with gen.Errorf("supervisor restart intensity exceeded (max %d in %ds): %w: %w", intensity, period, gen.ErrExceeded, lastChildReason). Both gen.ErrExceeded and the original child reason are reachable via errors.Is. See Restart Intensity.
Mailbox preservation across panic restart. When a process spawned with Options.PreserveMailbox: true terminates abnormally, the runtime captures its mailbox into Mailbox and wraps the original reason in Wrapped. The supervising parent automatically picks it up and hands it to the restart. See .
The framework defines several interfaces that provide access to different parts of the system.
The gen.Node interface is what you get when you start a node. It provides methods for spawning processes, managing applications, configuring networking, and controlling the node lifecycle.
Node operations can be called from any goroutine. The node manages processes but isn't itself an actor.
The gen.Process interface represents a running actor. It provides methods for sending messages, spawning children, linking to other processes, and managing the actor's lifecycle.
Actors typically embed this interface:
Process methods enforce state-based access control. Some operations are only available when the process is in certain states, ensuring actor model constraints are maintained.
The gen.Network interface manages distributed communication. It handles connections to remote nodes, routing, and service discovery.
Network transparency means sending messages to remote processes uses the same API as local processes. The gen.Network interface is where you configure how that transparency is achieved.
A gen.RemoteNode represents a connection to another Ergo node. Through this interface, you can spawn processes on the remote node or start applications there.
The remote operations require the target node to have enabled the corresponding permissions.
The gen.Application interface is the runtime view of a loaded application. It exposes application metadata (name, env, mode, state) and mutators for dynamic fields (tags, weight) that propagate to the registrar.
Application behaviors should embed app.Application rather than implementing the interface manually. The embed provides the framework entry point, default lifecycle callbacks, and the runtime binding. From inside callbacks the interface methods are available via the embed:
Processes within the application can access the same interface through Process.Application().
These types reflect a few design decisions worth understanding.
Hashing for readability - Node names are hashed in output to keep logs and traces readable while maintaining uniqueness. Full names can be verbose, especially in distributed systems with descriptive naming.
Separate types for concepts - gen.PID, gen.ProcessID, gen.Alias, and gen.Event are distinct types even though they could have been unified. Each represents a different way of addressing or identifying something in the system, and the type system helps keep these concepts clear.
Network-aware design - Many types include the node name. This isn't just for completeness - it's what enables network transparency. A gen.PID tells you not just which process, but which node, allowing the framework to route messages appropriately.
For detailed API documentation of these interfaces and types, refer to the godoc comments in the source code.
fmt.Printf("%s", gen.Atom("myprocess"))
// Output: 'myprocess'pid := gen.PID{Node: "node@localhost", ID: 1001, Creation: 1685523227}
fmt.Printf("%s", pid)
// Output: <2C323E75.0.1001>processID := gen.ProcessID{Name: "worker", Node: "node@localhost"}
fmt.Printf("%s", processID)
// Output: <2C323E75.worker>ref := node.MakeRef()
fmt.Printf("%s", ref)
// Output: Ref#<2C323E75.128194.23952.0>alias, err := process.CreateAlias()
if err != nil {
return err
}
fmt.Printf("%s", alias)
// Output: Alias#<2C323E75.128194.23952.0>event := gen.Event{Name: "user_login", Node: "node@localhost"}
fmt.Printf("%s", event)
// Output: Event#<2C323E75:user_login>env := gen.Env("database_url")
fmt.Printf("%s", env)
// Output: DATABASE_URLtype Error struct {
Msg string
Wrapped []error
Mailbox *ProcessMailbox
}return gen.Errorf("user %d: %w", userID, ErrPaymentDeclined)err := gen.Errorf("unable to update user %s: %w", id, ErrInvalidArgument)
// Msg = "unable to update user u42: invalid argument"
// Wrapped = [ErrInvalidArgument]err := gen.Errorf("request refused: %w %w", ErrInvalidArgument, ErrRetryable)
// Msg = "request refused: invalid argument retryable"
// Wrapped = [ErrInvalidArgument, ErrRetryable]inner := gen.Errorf("unable to update user %s: %w", id, ErrInvalidArgument)
outer := gen.Errorf("request %d failed: %w", reqID, inner)
// outer.Msg = "request 7 failed: unable to update user u42: invalid argument"
// outer.Wrapped = [inner]if errors.Is(reason, gen.ErrExceeded) {
// subtree died because its restart budget overflowed
}
if errors.Is(reason, ErrPaymentDeclined) {
// matched a marker from anywhere in the wrap chain
}gen.Errorf("failed: %w", nil) // Msg = "failed: %!w(<nil>)", Wrapped = []
gen.Errorf("failed: %s", cause) // %s instead of %w: text is fine, Wrapped = []node, err := ergo.StartNode("mynode@localhost", gen.NodeOptions{})
// node implements gen.Node interfacetype MyActor struct {
act.Actor // Embeds gen.Process
}
func (a *MyActor) HandleMessage(from gen.PID, message any) error {
// Process methods are available directly
a.Send(from, "reply")
return nil
}network := node.Network()
remoteNode, err := network.GetNode("remote@otherhost")remoteNode, err := network.GetNode("node@remotehost")
pid, err := remoteNode.Spawn("worker", gen.ProcessOptions{}, args)type MyApp struct {
app.Application
}
func (a *MyApp) Start(ref gen.Ref, mode gen.ApplicationMode) {
a.AddTag("ready") // visible to the cluster via the registrar
}Distributed tracing across actor message chains
In a distributed actor system, a single user request can touch dozens of processes across multiple nodes. Messages hop from actor to actor, crossing network boundaries invisibly. When something goes wrong (latency spikes, a message seems to disappear, an error surfaces three hops away from its cause) you need to follow the message trail across the entire cluster.
Traditional logging shows you individual perspectives. Process A logged a send at 10:00:00.001, process B logged a receive at 10:00:00.003. Connecting these fragments manually, with hundreds of messages per second, is impractical. Tracing solves this by giving the framework itself the job of tracking messages end-to-end.
A trace is an identity that follows a chain of causally related messages. When a process sends a message and the framework decides to track it, a 128-bit trace ID is generated and attached to that message. From that moment, the trace identity travels with every message in the chain. When the recipient handles the message and sends new messages of its own, those messages carry the same trace ID. When those recipients send further messages, the identity continues. The trace follows the causal chain across processes and nodes until the chain ends.
This is fundamentally different from HTTP tracing. In HTTP, a request enters a service, the service calls other services, and eventually a response comes back. The trace follows a request-response tree with clear boundaries. In an actor system, there are no such boundaries. A message arrives, the handler sends three async messages to different processes, each of those handlers sends more messages, and the chain branches and spreads across the cluster. There's no single "response" that marks the end. The trace ends when the last handler in the chain finishes without sending more traced messages.
Process B never opted into tracing. Neither did C or D. The trace reached them because the message carried it. This is the key property: you configure tracing on entry-point processes, and the trace propagates through the entire downstream chain automatically.
A complete trace has two layers, and it helps to keep them distinct.
The first layer is the message flow: which messages travelled between which processes, and how long each hop took. The framework records this layer on its own. You enable tracing at an entry point, and every observation along the chain appears with no further code. This is the skeleton of the trace, and most of this page is about it.
The second layer is the business operations: what each handler actually did while holding a message. Validating an order, reserving stock, creating an invoice. The framework cannot know these, they belong to your domain. You mark them with business spans, and they appear as named intervals nested inside the message flow.
The first layer tells you a message named ProcessOrder was handled in 50ms. The second tells you what those 50ms actually did. The first layer is mechanical and free; the second is where a trace starts telling the story of your system. This page builds the first layer first, then adds the second in .
A trace goes through three phases:
Birth. A process handles a message and calls Send, Call, or SendResponse. The framework checks: is there an active trace from the incoming message being handled? If yes, the outgoing message inherits it. If no, the framework asks the process's sampler: "should we start a new trace?" If the sampler says yes, a new trace ID is generated. If it says no, the message goes out untraced. The sampler is covered in the Enabling Tracing section below.
Propagation. The trace identity travels with the message. When the recipient's handler runs, the framework stores the trace as the "propagating context" for the duration of that handler. Every Send, Call, or SendResponse during the handler inherits the trace identity. When the handler returns, the context is restored. If the handler sends messages to five different processes, all five messages carry the same trace identity. Each recipient propagates it further in the same way.
End. A trace has no explicit end and no timeout. It ends naturally when the last handler in the chain finishes processing and sends no further messages. A trace that spans a 30-second Call timeout will simply have a 30-second gap between observations. The trace identity is a value in the message, not a timer.
As a trace flows through the system, the framework records observations at three points for each message:
Sent. Recorded when the message leaves the sender. This is the sender's perspective: who sent what, to whom, and when.
Delivered. Recorded when the message enters the recipient's mailbox. The recipient hasn't started processing yet, the message is queued.
Processed. Recorded when the recipient's handler returns. If the handler returned an error, the observation captures it.
These three points are not the trace itself. They are what gets recorded as the trace passes through. One message produces up to three observations. A trace spanning five messages across three nodes produces up to fifteen observations. Together, these observations reconstruct the complete message flow.
The timing gaps between observations tell you where time is spent:
For local messages, Sent and Delivered happen nearly simultaneously. For remote messages, the gap is the network transit time. This makes tracing particularly valuable in distributed systems: you can see exactly how much time is spent in transit versus in processing.
Each observation carries context: which node emitted it, the sender and recipient identities, the message type name, the actor behavior type, a timestamp, and any custom attributes. Together, the observations for a single trace form a tree that you can visualize as a waterfall in tools like Grafana Tempo or the Observer UI.
HTTP tracing typically records two points per span: the start and end of a service call. Actor tracing needs three because messages go through a mailbox. In HTTP, when service A calls service B, B starts processing immediately. In an actor system, when A sends to B, the message enters B's mailbox and waits. B might be busy handling a previous message. The wait time can be significant under load.
Without the Delivered point, you'd see Sent at time T and Processed at T+50ms, but you wouldn't know whether the 50ms was network latency, mailbox wait, or handler execution. With Delivered, you know: Sent to Delivered was 2ms (network), Delivered to Processed was 48ms (the message sat in the mailbox for 40ms and the handler took 8ms). This distinction is critical for diagnosing performance issues.
All message kinds that go through the framework's routing:
Response has no Processed because the response delivery completes the Call. There's no separate handler on the caller side. Spawn has no Delivered because it's not a mailbox delivery. Terminate has only Processed because it's an internal lifecycle event, not a message between two processes.
These are the observations the framework records on its own. On top of them you can record your own business operations as spans inside a handler, covered in .
Exit signals (SendExit) do not carry trace context. These are control-plane operations outside of message chains.
Events (SendEvent) also do not carry trace context. An event with a thousand subscribers would generate thousands of trace observations from a single publish, creating a storm that overwhelms exporters and backends. If you need to trace event-driven flows, trace the messages that your event handlers send in response to receiving events.
Delayed messages (SendAfter) do not carry trace context. A delayed message is a scheduled future action, not a continuation of the current processing chain. By the time it fires, the original handler has long finished. This prevents periodic self-tick patterns from creating infinite traces. Each tick is an independent starting point for the sampler. See the Delayed Messages section for details.
By default, no processes create traces. You enable tracing by setting a sampler that decides whether to start a new trace for each outgoing message.
Four sampler types are available:
The sampler is only consulted when there is no active trace. If a process is already handling a traced message, every outgoing message inherits the trace regardless of the sampler. This means you can set a sampler on a single entry-point process and the trace will follow the entire message chain automatically.
The sampler governs trace creation, and it does so for both outgoing messages and business spans (see ). A process with no sampler never starts a trace, but it still participates in every trace that reaches it.
TracingSamplerRatio(0.1) traces approximately 10% of messages. TracingSamplerRateLimit(100) allows at most 100 new traces per second. During traffic spikes the effective sampling rate drops, during quiet periods more messages are traced.
The sampler is set during Init() but only starts working when the process begins handling messages. Messages sent during Init() itself, including periodic ticks set up with SendAfter, are not traced. This is because Init() is a setup phase, not message processing. The sampler becomes active starting from the first HandleMessage or HandleCall invocation.
You can change a process's sampler without restarting it:
The node itself has a sampler for messages sent via node.Send() and node.Call():
Process samplers and the node sampler are independent.
If the built-in samplers don't fit your needs, implement the gen.TracingSampler interface:
Sample() is called for each outgoing message that doesn't already carry a trace. Return true to start a new trace. String() provides a human-readable description shown in Observer and inspection APIs.
The simplest traced scenario: process A handles a message and sends to process B on the same node.
When a.Send() executes, the sampler decides to start a new trace. The framework generates a trace identity shared by all observations for this message. Three observations are recorded:
Sent on the sender's node, capturing: sender PID, receiver PID, message type main.ProcessOrder, behavior gateway, the custom attribute service=gateway.
Delivered on the same node (it's local), capturing: the same message identity, the receiver's behavior name, the receiver's permanent attributes.
Processed after the receiver's HandleMessage
The receiver didn't set a sampler. It didn't need to. The trace arrived with the message and the observations were recorded automatically.
When process A on node X sends to process B on node Y, the trace crosses the network:
Sent is recorded on node X, but Delivered and Processed are recorded on node Y. The framework preserves the message's identity across the network, so all three observations can be correlated even though they were emitted on different nodes.
The gap between Sent and Delivered now represents real network latency. If you see a 50ms gap, that's 50ms of network transit.
The real power of tracing appears when messages form chains. Process A sends to B, and B sends to C and D while handling A's message. All hops share the same trace.
The gateway started the trace. The processor inherited it from the incoming message. The warehouse and billing processes also inherited it. Five messages, three nodes, one trace.
The propagation is automatic. During a handler, the framework stores the incoming message's trace context. Every Send, Call, or SendResponse during that handler carries the trace forward. When the handler returns, the context is restored to whatever it was before.
The trace captures causality: the processor's messages to warehouse and billing were sent because of the gateway's message to the processor. This creates a tree of messages that represents the complete processing flow for the original request.
Synchronous calls create two traced message flows within the same trace: the request going out and the response coming back.
The request and the response are separate messages, each with their own observations. They share a call reference (gen.Ref) that links them, so tools like Tempo and Observer can pair request and response even when multiple concurrent calls are in flight.
If the inventory process sends additional messages during HandleCall (for example, querying a database actor), those messages are also part of the same trace, linked causally to the incoming request.
In the actor model, a process handling a synchronous request can forward it to another process instead of responding directly. The relay wraps the original caller's identity and reference into the forwarded message, and the final recipient responds straight to the original caller:
The trace follows the entire chain: A's call to the relay, the relay's forward to the backend, and the backend's response to A. Three messages, potentially three nodes, one trace. The response skips the relay entirely, and the trace captures this topology accurately.
When HandleCall returns nil, nil (async response), the process stores the caller's identity and reference to respond later. Between the request handler and the eventual response, other messages may arrive. The response will happen in a different handler invocation, potentially with a different trace context.
If you need the response to be in the same trace as the original request, save the trace context alongside the caller identity:
PropagatingTrace() returns the current trace context. In HandleCall, this is the request's trace. Saving it and restoring before SendResponse ensures the response carries the original request's trace, regardless of which trace context the current handler is working with.
The save-restore pattern is important: SetPropagatingTrace changes the trace context for all subsequent operations in the handler. If you don't restore the previous context, the modified trace will leak beyond the current handler into all subsequent handler invocations. Every message the process sends from that point on will carry the leaked trace until another traced message arrives and resets it. Always save before, always restore after.
Traces show message flow. Custom attributes add business context that makes traces searchable and meaningful.
Attributes describe the place where a message was sent, delivered, or processed. They are part of the observation record, not part of the trace context. Over the network, only the trace ID and span ID travel with the message, just enough to link observations into a chain. Attributes stay local to the node that emitted the observation. This keeps the network overhead minimal and lets each process describe its own context independently.
Set on a process, attached to every observation from that process for its entire lifetime:
When a message passes through this process, its attributes appear on every observation where the process is a participant. If another process sends a message to PaymentService, the Delivered and Processed observations carry service=payment, version=2.1, region=eu-west. When PaymentService sends a message to someone else, the Sent observation carries the same attributes. The attributes describe the location in the system where the observation was recorded.
Setting an attribute with a key that already exists overwrites the value. Remove with RemoveTracingAttribute(key).
The node has its own permanent attributes, independent from process attributes:
Same mechanics as process attributes: set, overwrite, or remove at any time.
Set during message handling, scoped to a single handler invocation:
One-shot attributes appear on the observations emitted during this handler invocation: the Processed observation for the incoming message, and the Sent observations for outgoing messages.
The automatic clear happens only when the handled message carried a trace. It runs as part of emitting the Processed observation, and that emission is skipped for an untraced message - so a handler that sets one-shot attributes while nothing is being traced leaves them in place, and they surface on the next invocation that is traced. If a handler may run either way, clear them yourself with ClearTracingSpanAttributes() before returning.
A key collision between a one-shot and a permanent attribute is not resolved. Both are emitted, permanent first, one-shot second, with no de-duplication - what the exporter or backend does with two attributes of the same name is its own business, and most keep the last. Use distinct keys rather than relying on an override.
Different observations carry different attributes:
This means: the sender decides what context to attach at send time. The receiver's permanent identity (service name, version) appears on its Delivered and Processed observations. The receiver can add handler-specific context (order ID, customer) that appears on its Processed observation and on any Sent observations during that handler.
In Grafana Tempo or the Observer UI, search by any attribute value. If one observation in a trace has order_id=ORD-456, searching for it returns the complete trace, all observations across all nodes in the chain. You don't need the same attribute on every observation.
This makes attributes a powerful debugging tool. Set order_id on the entry-point process, and you can find the complete processing trace for any order by searching for its ID.
The ergo. prefix is reserved for framework-generated attributes (ergo.node, ergo.from, ergo.behavior). Attempts to set attributes with this prefix are silently ignored.
Everything up to this point traces the message flow: the framework records each Sent, Delivered, and Processed automatically. That tells you which messages moved and how long each hop took. It does not tell you what a handler did with its time.
Consider an order processor whose HandleMessage takes 50ms. The message-flow trace shows one Processed observation, 50ms after Delivered. Inside those 50ms the handler validated the order, reserved stock, and created an invoice, but the trace says nothing about that. To someone reading the waterfall the handler is an opaque 50ms block.
Business spans open that block. A business span is a named interval you start and end yourself, around a unit of work that matters to your domain. It appears in the trace as a child of the handler that opened it, with its own name and its own start and end time. This is the second layer of a trace: the message flow is the skeleton the framework records for free, and business spans are the operations you add to make the trace describe your system rather than its plumbing.
You open a span with StartTracingSpan and close it when the work is done. The idiomatic form pairs it with defer:
StartTracingSpan returns a scope; End closes it and records the interval. In the waterfall, validate-order appears nested under this handler's Processed observation, sized to how long validation took. The opaque block now has structure.
A span name should describe a business operation, not a mechanism. validate-order, reserve-inventory, create-invoice tell a reader what the system was doing. loop-iteration, step-2, call-helper do not. The name is what appears in the waterfall and what people search for, so spend it on domain meaning.
This is what makes business spans more than timers. Any message a handler sends while a span is open becomes a child of that span: the outgoing Sent observation, and the entire downstream chain it triggers, nest underneath.
The trace now reads: this handler ran reserve-inventory, which sent ReserveStock to the warehouse; then create-invoice, which called billing and waited for the response. Each operation and the messages it causes are grouped together. Without spans the two sends would sit side by side under the handler, with no indication of which operation each belongs to.
Spans nest. An operation made of smaller steps opens child spans:
reserve-inventory and create-invoice become children of fulfill-order, and their messages sit beneath them:
Close spans in the reverse order you opened them; the defer idiom does this naturally.
When an operation fails, close its span with EndError:
The span is marked with the error and stands out in the waterfall, pinpointing which operation failed inside an otherwise healthy-looking handler.
Attach context to a span the way you attach one-shot attributes to an observation:
The attributes are recorded on the span and are searchable like any other. The ergo. prefix is reserved and silently ignored here too.
Keep the two attribute calls straight: SetTracingSpanAttribute (from the section) attaches to the handler's own observations, while span.SetAttribute attaches to the business span you opened. Similar names, different targets.
A business span behaves differently depending on whether the handler is already part of a trace, and the same sampler that controls message tracing controls this.
When the handler is already in a trace (it is processing a message that carried one), the span attaches to that trace as a child. This is passive annotation: you are adding detail to a trace started elsewhere. It works whether or not the process has a sampler. A downstream processor that never sets a sampler still produces rich business spans, as long as the request that reached it was traced upstream. This is the common case: instrument handlers everywhere, and the spans light up inside whatever traces flow through.
When there is no active trace, the span consults the process's sampler. With a sampler that samples, the span starts a new trace and becomes its root: the process is acting as an initiator. With no sampler, the span is a no-op. An instrumented process never forces tracing into existence on its own.
So the decision "do I start traces?" lives in one place, the sampler, and applies uniformly to outgoing messages and to business spans. You instrument freely; whether a span lights up depends on whether its work is part of a sampled trace.
The worker from does periodic work on a self-tick. The tick arrives untraced, so its handler is not part of any trace. With a sampler set, a span at the top of the handler starts a fresh trace for that cycle, and everything the cycle does nests under it:
Each tick now produces one coherent trace rooted at work-cycle, instead of a loose collection of independently sampled sends. The next tick, scheduled with SendAfter, still starts fresh, so the cycles never chain into one endless trace.
Close every span you open. The defer span.End() form is the safest, the same discipline as defer mu.Unlock() or defer rows.Close(). A span left open when the handler returns is still recorded, but marked as unended, so a forgotten close shows up as a visible mistake rather than vanishing.
Spans are scoped to the handler that opens them. In a self-started handler (one with no inherited trace), sequential top-level spans each begin their own trace; to keep a sequence in a single trace, wrap it in an outer span and let the inner ones nest. In a handler that inherited a trace, sequential spans are simply siblings under that trace.
SendAfter does not carry trace context. This is a deliberate design choice: a delayed message is a future action, not a continuation of the current processing chain.
Consider a common pattern, a process that does periodic work via a self-tick:
Each tick arrives as an untraced message. The sampler on the worker decides independently for each Send(targetPID, DoWork{}) whether to create a trace. The SendAfter at the end schedules the next tick without trace context, breaking the chain and ensuring the next tick starts fresh.
To make each tick a single coherent trace rather than a set of independently sampled sends, wrap the cycle's work in a business span. See .
If SendAfter inherited the trace, the first tick that happened to be traced would create an infinite trace: tick carries trace, handler sends traced tick, next handler sends traced tick, forever. A process running for days would accumulate millions of observations in a single trace. Decoupling SendAfter from the trace context prevents this.
The same applies to SendAfter to other processes. If you need a delayed message to carry trace context, send it through a regular Send to an intermediary that schedules the delay, or store the trace context and restore it when the delayed action triggers (the same pattern as Async Response above).
Send to self behaves like Send to any other process. The message carries the current trace context. This is consistent and enables patterns like async HandleCall where a process sends work to itself and responds later within the same trace.
For periodic self-loops, use SendAfter which does not carry trace context. This is the natural choice for tick patterns since SendAfter provides the timing control that loops need. Each tick starts fresh, and the sampler decides independently whether to trace it.
If your actor uses Send to itself for a finite internal sequence (state machine, batch processing), the internal steps will appear in the trace. For a three-step state machine triggered by a traced message, this adds six extra observations. This is proportional to the work done and finite, not a concern in practice.
When a process spawns a child during a traced handler, the spawn itself is part of the trace.
The framework records two observations for the spawn:
Sent. Emitted before the child's Init() runs. This is "spawn initiated."
Processed. Emitted after Init() returns. If Init() returned an error, the error is recorded in this observation's Error field.
The gap between Sent and Processed is the Init() execution time. If a spawn is slow, you'll see it in the trace.
After Init() completes, the child process starts with a clean slate, no inherited trace context. Messages the child sends during Init() are not traced. The child's sampler decides whether to trace its own outgoing messages starting from the first HandleMessage or HandleCall. The Send(pid, BeginWork{}) in the example above carries the parent's trace (it's a regular Send during the parent's traced handler), so the child receives and processes it within the parent's trace.
A terminate observation is recorded when a process terminates while handling a traced message. If the handler returns an error that causes the process to exit, the framework records the termination reason in the same trace as the message that caused the crash. This gives you the complete picture in one trace: the message arrived, the handler failed, the process terminated.
Processes that terminate between handler invocations (normal shutdown, supervisor stop, node.Kill) do not generate a terminate observation. Normal lifecycle events don't produce tracing noise.
Observations go nowhere by themselves. To see them, you register one or more tracing exporters on the node. This works similarly to loggers: a node can have multiple loggers, each receiving log messages according to its own level filter. A node can have multiple tracing exporters, each receiving observations according to its own flags.
The framework emits all observations unconditionally for traced messages. Each exporter declares which types of observations it wants to receive, and the framework delivers only those. One exporter might receive everything for a waterfall UI, while another on the same node receives only Sent observations for counting outgoing messages.
When you register an exporter, you specify which observations it should receive:
Combine with bitwise OR:
Business spans are delivered with gen.TracingFlagReceive, alongside Delivered and Processed. An exporter that wants to see business operations must request that flag.
Process-based. An actor process that receives observations in its mailbox. Use this when the exporter needs actor capabilities: batching with timers, sending over the network, accessing node services. This is how Observer and Pulse work internally.
The process implements HandleSpan(gen.TracingSpan) to process each observation. If the process's mailbox is full, observations are silently dropped. Ensure the exporter can keep up with the observation rate.
Behavior-based. A simple implementation of the gen.TracingBehavior interface. Use it for lightweight exporters that don't need actor capabilities.
HandleSpan does not run on the goroutine that emitted the span. Each object exporter gets a worker of its own with a 1024-span queue, and emitting is a non-blocking push onto it, so a slow exporter cannot stall the code that produced the span. Panics inside HandleSpan are recovered and logged instead of taking the node down.
Keep HandleSpan fast anyway. It does not block the emitter or the other exporters, but its own queue is bounded: once 1024 spans are waiting on it, the ones behind them are dropped.
At node startup:
At runtime:
Each exporter has a unique name. Attempting to register a name that's already taken returns gen.ErrTaken. A process can only be registered as one exporter. A second attempt returns gen.ErrNotAllowed.
Removing a behavior-based exporter calls its Terminate() method. Exporters can be added and removed at any time while the node is running.
Two ready-made exporters are available out of the box.
provides real-time tracing visualization directly in the web UI. It connects to a specific node and shows traces passing through that node, useful for live debugging and runtime sampler control. Since Observer sees only one node at a time, traces that span multiple nodes will appear partial. See for details.
exports traces to an OTLP-compatible backend (Grafana Tempo, Jaeger). Each node runs its own Pulse instance, sending observations to a shared collector. The backend assembles complete cross-cluster traces from all nodes, so you can see the full message chain end-to-end. See the for setup and configuration.
In production, you rarely want to trace everything. Set a ratio sampler on your entry-point processes and let propagation handle the rest:
One percent of requests are traced end-to-end across the entire cluster. The other 99% have near-zero overhead: one Sample() call returning false.
Downstream processes don't need samplers. They inherit traces from incoming messages. This means adding tracing to a complex system requires changes only at the entry points.
When traffic volume varies, TracingSamplerRateLimit provides a steady flow of traces regardless of load:
This creates at most 50 new traces per second. During a traffic spike, the effective sampling rate drops. During quiet periods, more messages are traced.
This is useful when your tracing backend or exporters have throughput limits. You get consistent trace volume without overwhelming the pipeline.
Something is wrong with a particular process. Enable full tracing on it without restarting:
Or through the Observer UI: open the process, go to Config, set the sampler to "always". Every message this process handles and every message it sends will be traced. When you're done investigating, set it back to "disable."
Because trace propagation is automatic, you'll see not just this process's messages but the entire downstream chain. If the process calls a remote service, you'll see the round-trip. If it spawns workers, you'll see the spawn and the workers' activity.
A customer reports a problem with order ORD-789. You need to see what happened:
In Grafana Tempo, search for order_id=ORD-789. The complete trace appears: every message in the processing chain, across every node, with timing at every hop. You can see where the latency was, which service returned an error, and what happened next.
This requires that the entry-point process was tracing when order ORD-789 came through. With 1% sampling, you won't have traces for every request. For critical flows where you always need traces, use TracingSamplerAlways on the entry-point process or a higher ratio.
During an incident, you need more visibility. Increase sampling temporarily:
You can do this through the Observer UI without any code changes: open the process, change the sampler in the Config tab, investigate, and set it back.
As traces propagate through message chains, they form trees. Understanding the tree structure helps when reading traces in Tempo or Observer.
Business spans add a second dimension to these trees. Message observations are points in time (Sent, Delivered, Processed); a business span is an interval, drawn as a bar spanning its duration, with the messages it sent nested inside it. A handler's Processed observation, the spans opened during that handler, and the messages those spans sent form one subtree.
The simplest tree: A sends to B, B sends to C, C sends to D.
Each message is a child of the message that caused it. In a waterfall view, you see a staircase pattern: each hop starts when the previous handler runs.
One handler sends to multiple recipients:
B's handler sends three messages. All three are children of B's incoming message. In a waterfall view, the three sends appear at roughly the same timestamp, fanning out from B's processing.
B calls C synchronously, then uses the result to send to D:
In the waterfall, you see B waiting for C's response before sending to D. The gap between the response arriving and D's Sent observation shows B's processing time between the call return and the next send.
In a microservice-style architecture with many nodes, traces can span many hops:
Each arrow is a message with up to three observation points. The complete trace might have 15-20 observations across 5 nodes. In Tempo's waterfall view, you see exactly where time is spent: if the warehouse is slow, the gap between its Delivered and Processed observations will be large.
For specialized needs beyond Pulse and Observer, you can write your own exporter. Here's an example that counts observations by kind:
Register it at node startup:
Or register at runtime:
The flags on the exporter determine which observations it receives. The counter above only gets Sent observations (because of TracingFlagSend). To also receive Delivered and Processed, add gen.TracingFlagReceive.
For more complex exporters that need actor capabilities (sending messages, using timers, accessing the network), register a process as an exporter with TracingExporterAddPID and implement HandleSpan in your actor. This is how Pulse works: a pool of actor processes that batch observations and flush them over HTTP.
How the Pub/Sub system works internally
This document explains how Ergo Framework's pub/sub system works under the hood. It's written for developers who want to understand the architecture, network behavior, and performance characteristics when building distributed systems.
For basic usage, see Links and Monitors and Events. This document assumes you're familiar with those concepts and focuses on how the system works internally.
Links, monitors, and events look like separate features when you use them. But underneath, they share the same mechanism. Understanding this unification explains why the system behaves consistently and why certain optimizations work.
Every interaction in the pub/sub system follows one pattern:
A consumer subscribes to a target and receives notifications about that target.
This applies whether you're linking to a process, monitoring a registered name, or subscribing to an event stream. The differences are in what you subscribe to and what notifications you receive.
1. Consumer - The process creating the subscription. This is the process that will receive notifications when something happens to the target.
2. Target - What the consumer subscribes to. Targets come in several types:
3. Subscription Type - How the consumer wants to receive notifications:
The combination of target type and subscription type determines what message you receive:
The targets divide into two categories based on what notifications they generate:
Implicit Events - Processes, names, aliases, and nodes generate termination notifications automatically. The target doesn't do anything special - when it terminates (or disconnects, for nodes), the framework generates notifications for all subscribers.
Explicit Events - Registered events generate both published messages AND termination notifications. A producer process explicitly registers an event and publishes messages to it. When the producer terminates or unregisters the event, subscribers also receive termination notification.
The key difference: implicit events give you one notification (termination). Explicit events give you N published messages plus termination notification.
This unified architecture has practical benefits:
Consistent behavior - The same subscription and notification mechanics work for all target types. Once you understand how monitors work for processes, you understand how they work for events.
Shared optimizations - Network optimizations (covered later) apply to all subscription types. Whether you're monitoring 100 remote processes or subscribing 100 consumers to a remote event, the same sharing mechanism kicks in.
Predictable cleanup - Termination cleanup works identically for all subscriptions. When a process terminates, all its subscriptions are cleaned up using the same code path.
When you subscribe to a target on the same node, the operation is simple and fast.
The call returns instantly. There's no network communication, no blocking. The node records your subscription in memory.
The Target Manager maintains subscription records. When a process terminates, Target Manager looks up all subscribers and delivers notifications to their mailboxes. For links, notifications go to the Urgent queue. For monitors, they go to the System queue.
Instant subscription - No waiting, no blocking. The subscription is recorded synchronously.
Guaranteed notification - If the target terminates after you subscribe, you will receive notification. The notification mechanism is part of the termination process itself.
Asynchronous delivery - Notifications arrive in your mailbox like any other message. You process them in your HandleMessage callback.
Automatic cleanup - When the target terminates and you receive notification, the subscription is removed automatically. You don't need to unsubscribe.
Remote subscriptions involve network communication but provide the same guarantees as local subscriptions.
The call blocks while the subscription request travels to the remote node and the response returns. This typically takes milliseconds on a local network.
The subscription request travels to the remote node's Target Manager. It validates that the target exists (returning an error if not), records the subscription, and sends confirmation. Once established, termination notifications travel back over the network.
Both local and remote subscriptions validate that the target exists:
For events, the target node validates the event is registered:
Remote subscriptions guarantee you receive exactly one termination notification. This guarantee holds even when networks fail. Two paths can deliver your notification:
Path 1: Normal Delivery
The target terminates normally. The remote node sends the notification over the network. You receive it with the actual termination reason:
Path 2: Connection Failure
The network connection fails before the notification arrives (or before the target even terminates). Your local node detects the disconnection and generates notifications for all subscriptions to targets on the failed node:
Why This Works
You're guaranteed notification through one of two mechanisms:
Remote node delivers it (normal case)
Local node generates it when detecting connection failure (failover)
The Reason field tells you which path occurred. Your code typically handles both the same way - the target is no longer accessible regardless of why.
This failover mechanism compensates for network unreliability. You write code assuming notifications always arrive, because they do.
This section describes the optimization that makes distributed pub/sub practical at scale. Without it, many common patterns would be impractical.
Consider a realistic scenario:
Naive implementation: Each MonitorPID call creates a separate network subscription. Result:
100 network round-trips to create subscriptions
100 subscription records on the remote node
100 network messages when the coordinator terminates
This doesn't scale. With 1000 workers, you'd have 1000 network messages just to deliver one termination notification.
The framework automatically detects when multiple local processes subscribe to the same remote target and shares the network subscription.
What you observe:
The first subscription to a remote target requires network communication. Every subsequent subscription from the same node to the same target returns instantly - it shares the existing network subscription.
When the remote target terminates:
Remote node sends ONE notification message to your node
Your node receives it and looks up all local subscribers to that target
Your node delivers individual notifications to each subscriber's mailbox
Network cost comparison for 100 subscribers:
The same optimization applies to event publishing. When you publish an event with subscribers on multiple nodes:
The framework groups subscribers by node and sends ONE message per node:
What the producer sees:
What subscribers see:
Consider a market data feed with 1 million subscribers distributed across 10 nodes:
When the producer publishes one price update:
The optimization transforms O(N) network cost (where N = total subscribers) into O(M) cost (where M = number of nodes). For distributed systems with many subscribers per node, this is the difference between practical and impossible.
Actual benchmark results (from the benchmark):
This optimization enables patterns that would be impractical otherwise:
Worker pools monitoring coordinators:
Distributed caching with invalidation:
Hierarchical supervision across nodes:
High-frequency event streaming:
The optimization applies when multiple processes on the SAME node subscribe to the SAME remote target.
These share:
These don't share:
Buffered events receive partial optimization. The subscription is shared, but each subscriber must retrieve buffer contents individually.
Event buffers store recent messages for new subscribers:
When a subscriber joins, they receive the buffered messages:
The problem: different subscribers joining at different times need different buffer contents.
If subscriptions were fully shared, all subscribers would receive the same buffer - incorrect for late subscribers.
First subscriber: Network round-trip to create subscription AND retrieve buffer.
Subsequent subscribers: Network round-trip to retrieve current buffer (subscription already exists).
Published messages: Still optimized - one network message per node, distributed locally.
Use buffers when:
New subscribers need recent history (last N configuration updates)
Subscribers might miss messages during brief disconnections
State can be reconstructed from recent messages
Subscriber count is moderate
Avoid buffers when:
Real-time streaming where history isn't useful
High subscriber count across many nodes (each pays network cost)
Messages are only meaningful at publish time
Memory constraints (buffers consume memory on producer node)
Practical guidance:
Producers can receive notifications when subscriber interest changes. This enables demand-driven data production.
You only receive notifications when crossing the zero threshold. The notifications answer: "is anyone listening?" - not "how many are listening?"
Node-level events do not produce these notifications. The producer of a node-level event is the node core, which does not consume MessageEventStart or MessageEventStop messages.
The producer idles when nobody's listening, avoiding unnecessary API calls and resource usage. When subscribers appear, it starts producing. When all subscribers leave, it stops.
Notifications work across nodes. Remote subscribers count toward "someone is listening":
The producer doesn't know or care whether subscribers are local or remote. The notification mechanism handles it transparently.
Each event tracks subscribers independently:
Subscriptions clean up automatically when any participant terminates. This eliminates resource leaks from forgotten subscriptions.
All subscribers receive notification. The subscription ceases to exist - there's nothing to unsubscribe from.
Your subscriptions are removed from:
Local subscription records
Remote nodes (for remote subscriptions)
If you were the last local subscriber to a remote target, the network subscription is removed. Otherwise, it stays for remaining local subscribers.
The reason tells a subscriber which of the two happened. An explicit UnregisterEvent dispatches gen.ErrUnregistered. A producer that terminates dispatches its own termination reason instead - gen.TerminateReasonNormal, a panic, whatever it died of. So errors.Is(reason, gen.ErrUnregistered) means the producer is alive and has stopped publishing, and any other reason means the producer itself is gone.
All subscriptions involving the failed node are cleaned up. If the node reconnects later, you need to re-subscribe - the framework doesn't automatically restore subscriptions.
You can explicitly remove subscriptions:
Explicit unsubscription is useful when:
You want to stop watching before termination
You're switching to a different target
You're implementing connection retry logic
But in most cases, you don't need explicit unsubscription. Let termination handle cleanup.
When a process terminates, cleanup happens in a specific order:
Process state changes to Terminated
All outgoing subscriptions (where process is consumer) are removed
All incoming subscriptions (where process is target) generate notifications
Process resources are freed
This ordering ensures:
You don't receive notifications after your process starts terminating
Subscribers to you receive notifications before your resources are freed
No race conditions between notification delivery and cleanup
For subscriptions:
First subscription to remote target: network round-trip
Additional subscriptions to same target: instant
Unbuffered events: full sharing
Buffered events: shared delivery, individual buffer retrieval
For notifications:
One network message per subscriber node
Local distribution to all subscribers on that node
Cost scales with number of nodes, not number of subscribers
For cleanup:
Automatic on any termination
No resource leaks possible
No manual unsubscription required
Building production clusters with Ergo technologies
Ergo provides a complete technology stack for building distributed systems. Service discovery, load balancing, failover, observability - all integrated and working together. No external dependencies except the registrar. No API gateways, service meshes, or orchestration layers between your services.
This chapter shows how to use Ergo technologies to build production clusters. You'll see how service discovery enables automatic load balancing, how the leader actor provides failover, how metrics and Observer give you visibility into cluster state. Each technology solves a specific problem; together they cover the full spectrum of distributed system requirements.
Traditional microservice architectures pay a heavy integration tax. Each service needs:
HTTP/gRPC endpoints for communication
Response
Return value from HandleCall
Sent, Delivered
Spawn
Process creation
Sent, Processed
Terminate
Process termination
Processed
Sent to Delivered
Network latency (remote) or scheduling delay (local)
Delivered to Processed
Mailbox wait time + handler execution time
Sent to Processed
Send
Asynchronous message (Send)
Sent, Delivered, Processed
Request
Synchronous call (Call)
Sent
Sender's permanent + one-shot attributes
Delivered
Receiver's permanent attributes
Processed
Total end-to-end latency for this message
Sent, Delivered, Processed
Receiver's permanent + one-shot attributes
Alias
gen.Alias{...}
A process alias
Node
gen.Atom("node@host")
A network connection
Event
gen.Event{Name: "prices", Node: "node@host"}
A registered event
Down message
MessageDown* → System queue
Continues running
Notification delivery
N network messages
1 network message
Unsubscribe (not last)
1 network round-trip
0 (instant)
Unsubscribe (last)
1 network round-trip
1 network round-trip
Unsubscribe all
100 round-trips
1 round-trip
Total
300 network operations
3 network operations
Published messages
1 message per node
1 message per node
Termination notification
1 message per node
1 message per node
3 → 2, 2 → 1, etc.
None
Shared subscriptions
Multiple local subscribers share one network subscription to remote target
Event publishing
One network message per subscriber node, local fanout to subscribers
Buffered events
Shared delivery, but each subscriber retrieves buffer individually
Producer notifications
MessageEventStart/Stop when crossing zero subscriber threshold
Automatic cleanup
All subscriptions cleaned up on any termination
PID
gen.PID{Node: "node@host", ID: 100}
A specific process instance
ProcessID
gen.ProcessID{Name: "worker", Node: "node@host"}
Link
Exit signal
MessageExit* → Urgent queue
Terminates by default
First subscription
1 network round-trip
1 network round-trip
N additional subscriptions
N network round-trips
Subscribe all
100 round-trips
1 round-trip
Notification
100 messages
Without optimization
1,000,000
With optimization
10
First subscription
1 network round-trip
1 network round-trip
Additional subscriptions
Instant (shared)
0 → 1 (first subscriber)
MessageEventStart
1 → 0 (last subscriber leaves)
MessageEventStop
1 → 2, 2 → 3, etc.
Unified architecture
Links, monitors, and events share the same subscription mechanism
Local subscriptions
Instant creation, guaranteed notification, asynchronous delivery
Remote subscriptions
A registered name
Monitor
0 (instant)
1 message
Network round-trip (buffer retrieval)
None
Network round-trip, guaranteed notification via normal or failover path
func (a *OrderProcessor) Init(args ...any) error {
a.SetTracingSampler(gen.TracingSamplerAlways)
return nil
}gen.TracingSamplerDisable // never start traces (default)
gen.TracingSamplerAlways // trace every outgoing message
gen.TracingSamplerRatio(0.01) // trace 1% of messages
gen.TracingSamplerRateLimit(100) // at most 100 new traces per secondnode.SetProcessTracingSampler(pid, gen.TracingSamplerAlways)node.SetTracingSampler(gen.TracingSamplerRatio(0.01))type TracingSampler interface {
Sample() bool
String() string
}func (a *gateway) Init(args ...any) error {
a.SetTracingSampler(gen.TracingSamplerAlways)
a.SetTracingAttribute("service", "gateway")
return nil
}
func (a *gateway) HandleMessage(from gen.PID, message any) error {
req := message.(IncomingRequest)
a.Send(processorPID, ProcessOrder{ID: req.OrderID})
return nil
}func (p *processor) HandleMessage(from gen.PID, message any) error {
order := message.(ProcessOrder)
p.SetTracingSpanAttribute("order_id", order.ID)
p.Send(warehousePID, ReserveStock{OrderID: order.ID})
p.Send(billingPID, CreateInvoice{OrderID: order.ID})
return nil
}func (c *client) HandleMessage(from gen.PID, message any) error {
to := gen.ProcessID{Name: "inventory", Node: "warehouse@host"}
result, err := c.Call(to, CheckStockRequest{SKU: "WIDGET-42"})
if err != nil {
c.Log().Warning("stock check failed: %s", err)
return nil
}
resp := result.(CheckStockResponse)
c.Log().Info("stock level: %d", resp.Available)
return nil
}
func (inv *inventory) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
req := request.(CheckStockRequest)
level := inv.checkWarehouse(req.SKU)
return CheckStockResponse{Available: level}, nil
}func (r *relay) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
req := request.(Request)
target := gen.ProcessID{Name: "backend", Node: "target@node"}
r.Send(target, MessageForward{
OriginalFrom: from,
OriginalRef: ref,
Payload: req,
})
return nil, nil // no direct response; backend will respond to the original caller
}
func (b *backend) HandleMessage(from gen.PID, message any) error {
fwd := message.(MessageForward)
result := b.process(fwd.Payload)
// process message
b.SendResponse(fwd.OriginalFrom, fwd.OriginalRef, result)
return nil
}type pendingCall struct {
From gen.PID
Ref gen.Ref
Tracing gen.Tracing
}
func (s *service) HandleCall(from gen.PID, ref gen.Ref, request any) (any, error) {
s.pending = pendingCall{
From: from,
Ref: ref,
Tracing: s.PropagatingTrace(),
}
return nil, nil
}
func (s *service) HandleMessage(from gen.PID, message any) error {
// some event triggers the response
saved := s.PropagatingTrace()
s.SetPropagatingTrace(s.pending.Tracing)
s.SendResponse(s.pending.From, s.pending.Ref, result)
s.SetPropagatingTrace(saved)
return nil
}func (a *PaymentService) Init(args ...any) error {
a.SetTracingSampler(gen.TracingSamplerRatio(0.01))
a.SetTracingAttribute("service", "payment")
a.SetTracingAttribute("version", "2.1")
a.SetTracingAttribute("region", "eu-west")
return nil
}node.SetTracingAttribute("env", "production")
node.SetTracingAttribute("cluster", "payments-eu")func (a *OrderProcessor) HandleMessage(from gen.PID, message any) error {
order := message.(Order)
a.SetTracingSpanAttribute("order_id", order.ID)
a.SetTracingSpanAttribute("customer", order.CustomerID)
a.SetTracingSpanAttribute("amount", fmt.Sprintf("%.2f", order.Total))
a.Send(warehousePID, ReserveStock{OrderID: order.ID})
a.Send(billingPID, CreateInvoice{OrderID: order.ID})
return nil
}func (p *processor) HandleMessage(from gen.PID, message any) error {
order := message.(ProcessOrder)
span := p.StartTracingSpan("validate-order")
err := p.validate(order)
span.End()
if err != nil {
return err
}
// ... continue processing
return nil
}func (p *processor) HandleMessage(from gen.PID, message any) error {
order := message.(ProcessOrder)
reserve := p.StartTracingSpan("reserve-inventory")
p.Send(warehousePID, ReserveStock{OrderID: order.ID})
reserve.End()
invoice := p.StartTracingSpan("create-invoice")
_, err := p.Call(billingPID, CreateInvoice{OrderID: order.ID})
invoice.End()
if err != nil {
return err
}
return nil
}func (p *processor) HandleMessage(from gen.PID, message any) error {
order := message.(ProcessOrder)
fulfill := p.StartTracingSpan("fulfill-order")
reserve := p.StartTracingSpan("reserve-inventory")
p.Send(warehousePID, ReserveStock{OrderID: order.ID})
reserve.End()
invoice := p.StartTracingSpan("create-invoice")
p.Call(billingPID, CreateInvoice{OrderID: order.ID})
invoice.End()
fulfill.End()
return nil
}ProcessOrder (Processed, 50ms)
└─ fulfill-order
├─ reserve-inventory
│ └─ ReserveStock (Sent → Delivered → Processed at warehouse)
└─ create-invoice
└─ CreateInvoice (Sent → Delivered → Processed at billing)invoice := p.StartTracingSpan("create-invoice")
_, err := p.Call(billingPID, CreateInvoice{OrderID: order.ID})
if err != nil {
invoice.EndError(err)
return err
}
invoice.End()invoice := p.StartTracingSpan("create-invoice")
invoice.SetAttribute("order.id", order.ID)
invoice.SetAttribute("amount", fmt.Sprintf("%.2f", order.Total))
// ...
invoice.End()func (w *worker) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageTick:
cycle := w.StartTracingSpan("work-cycle")
w.Send(targetPID, DoWork{})
cycle.End()
w.SendAfter(w.PID(), messageTick{}, 3*time.Second)
}
return nil
}func (w *worker) Init(args ...any) error {
w.SetTracingSampler(gen.TracingSamplerAlways)
w.SendAfter(w.PID(), messageTick{}, 3*time.Second)
return nil
}
func (w *worker) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case messageTick:
w.Send(targetPID, DoWork{})
w.SendAfter(w.PID(), messageTick{}, 3*time.Second)
}
return nil
}func (m *manager) HandleMessage(from gen.PID, message any) error {
task := message.(NewTask)
pid, err := m.Spawn(workerFactory, gen.ProcessOptions{}, task.Config)
if err != nil {
return err
}
m.Send(pid, BeginWork{TaskID: task.ID})
return nil
}gen.TracingFlagSend // Sent observations
gen.TracingFlagReceive // Delivered and Processed observations
gen.TracingFlagProcs // Spawn and Terminate lifecycle events// receive everything
flags := gen.TracingFlagSend | gen.TracingFlagReceive | gen.TracingFlagProcs
// only message delivery observations
flags := gen.TracingFlagReceivenode.TracingExporterAddPID(pid, "my-exporter",
gen.TracingFlagSend | gen.TracingFlagReceive | gen.TracingFlagProcs)type TracingBehavior interface {
HandleSpan(TracingSpan)
Terminate()
}node.TracingExporterAdd("counter", &spanCounter{},
gen.TracingFlagSend | gen.TracingFlagReceive)options := gen.NodeOptions{
Tracing: gen.TracingOptions{
Exporters: []gen.TracingExporter{
{
Name: "my-exporter",
Exporter: &myExporter{},
Flags: gen.TracingFlagSend | gen.TracingFlagReceive,
},
},
},
}node.TracingExporterAdd("counter", &spanCounter{}, gen.TracingFlagSend)
node.TracingExporterAddPID(pid, "observer", gen.TracingFlagSend | gen.TracingFlagReceive | gen.TracingFlagProcs)names := node.TracingExporters() // list registered exporter names
node.TracingExporterDelete("name") // remove by name
node.TracingExporterDeletePID(pid) // remove by PIDfunc (gw *APIGateway) Init(args ...any) error {
gw.SetTracingSampler(gen.TracingSamplerRatio(0.01))
gw.SetTracingAttribute("service", "api-gateway")
return nil
}gw.SetTracingSampler(gen.TracingSamplerRateLimit(50))node.SetProcessTracingSampler(problemPID, gen.TracingSamplerAlways)func (a *OrderProcessor) HandleMessage(from gen.PID, message any) error {
order := message.(Order)
a.SetTracingSpanAttribute("order_id", order.ID)
// ... process the order
return nil
}// before: 1% sampling
node.SetProcessTracingSampler(gatewayPID, gen.TracingSamplerRatio(0.01))
// during incident: trace everything
node.SetProcessTracingSampler(gatewayPID, gen.TracingSamplerAlways)
// after resolution: back to normal
node.SetProcessTracingSampler(gatewayPID, gen.TracingSamplerRatio(0.01))func (b *processor) HandleMessage(from gen.PID, message any) error {
result, err := b.Call(validatorPID, ValidateRequest{...})
if err != nil {
return err
}
b.Send(executorPID, ExecuteRequest{Validated: result})
return nil
}type traceCounter struct {
sends int64
requests int64
responses int64
}
func (tc *traceCounter) HandleSpan(span gen.TracingSpan) {
switch span.Kind {
case gen.TracingKindSend:
atomic.AddInt64(&tc.sends, 1)
case gen.TracingKindRequest:
atomic.AddInt64(&tc.requests, 1)
case gen.TracingKindResponse:
atomic.AddInt64(&tc.responses, 1)
}
}
func (tc *traceCounter) Terminate() {}options := gen.NodeOptions{
Tracing: gen.TracingOptions{
Exporters: []gen.TracingExporter{
{
Name: "counter",
Exporter: &traceCounter{},
Flags: gen.TracingFlagSend,
},
},
},
}node.TracingExporterAdd("counter", &traceCounter{},
gen.TracingFlagSend)LinkPID(target) → MessageExitPID when target terminates
MonitorPID(target) → MessageDownPID when target terminates
LinkEvent(event) → MessageExitEvent when event ends
MonitorEvent(event) → MessageDownEvent when event ends// Subscribe to process - implicit event
process.MonitorPID(targetPID)
// When targetPID terminates, you receive MessageDownPID
// The target process didn't send anything - framework generated it// Producer registers event - explicit event source
token, _ := producer.RegisterEvent("prices", gen.EventOptions{})
// Producer publishes messages
producer.SendEvent("prices", token, PriceUpdate{...})
// Subscriber receives:
// - MessageEvent for each published message
// - MessageDownEvent when producer terminates or unregisters// Subscribe to local process
err := process.MonitorPID(localTarget)
// Returns immediately - no waiting// Subscribe to remote process
err := process.MonitorPID(remotePID)
// Blocks briefly during network round-trip
// May return error if target doesn't exist or network fails// If target doesn't exist, you get an error
err := process.MonitorPID(nonExistentPID)
// err == gen.ErrProcessUnknown
// If target exists but already terminated
err := process.MonitorPID(terminatedPID)
// err == gen.ErrProcessTerminated// If event isn't registered, you get an error
_, err := process.MonitorEvent(gen.Event{Name: "unknown", Node: "node@host"})
// err == gen.ErrEventUnknownfunc (w *Worker) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case gen.MessageDownPID:
// Normal termination reasons:
// - gen.TerminateReasonNormal (clean shutdown)
// - gen.TerminateReasonShutdown (requested shutdown)
// - gen.TerminateReasonPanic (crash)
// - gen.TerminateReasonKill (forced kill)
// - Custom error (process returned error)
log.Printf("Target terminated: %v", msg.Reason)
}
return nil
}func (w *Worker) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case gen.MessageDownPID:
if msg.Reason == gen.ErrNoConnection {
// Connection failed
// Target might still be running on an isolated node
// Or it might have terminated - we can't know
log.Printf("Lost connection to target's node")
}
}
return nil
}case gen.MessageDownPID:
// Whether normal termination or connection failure,
// the target is gone from our perspective
w.handleTargetGone(msg.PID, msg.Reason)// On node A, 100 worker processes all monitor the same coordinator on node B
coordinatorPID := gen.PID{Node: "nodeB@host", ID: 500}
for i := 0; i < 100; i++ {
workers[i].MonitorPID(coordinatorPID)
}// First worker subscribes
err := worker1.MonitorPID(coordinatorPID)
// Takes a few milliseconds - network round-trip
// Second worker subscribes to SAME target
err := worker2.MonitorPID(coordinatorPID)
// Returns instantly - no network communication
// 98 more workers subscribe
// All return instantly// All 100 workers receive notification
// But only ONE network message was sent
// Your node distributed it locally
func (w *Worker) HandleMessage(from gen.PID, message any) error {
case gen.MessageDownPID:
// You can't tell if you're the only subscriber
// or one of 1000 subscribers
// The timing and behavior are identical
}// Producer on node A publishes
process.SendEvent("market.prices", token, PriceUpdate{Symbol: "BTC", Price: 42000})// Publish returns immediately
process.SendEvent("market.prices", token, update)
// You don't wait for delivery
// You don't know how many subscribers there are
// You don't know which nodes they're onfunc (c *Consumer) HandleEvent(event gen.MessageEvent) error {
// Event arrives in your mailbox
// Same timing whether you're the only subscriber or one of thousands
// Same timing whether producer is local or remote
return nil
}Configuration:
- 1 producer on node A
- 10 consumer nodes (B through K)
- 100,000 subscribers per consumer node
- 1,000,000 total subscribersTotal subscribers: 1000000
Consumer nodes: 10
Subscribers per node: 100000
Time to publish: 64.125µs
Time to deliver all: 342.414375ms
Network messages sent: 10 (1 per consumer node)
Delivery rate: 2920438 msg/sec// 50 workers on each of 10 nodes monitor a shared coordinator
// Network cost: 10 subscriptions, not 500
for i := 0; i < 50; i++ {
worker := SpawnWorker()
worker.MonitorPID(coordinatorPID)
}// Cache instances on every node subscribe to invalidation events
// When data changes, ONE message per node delivers invalidation
// Each node updates all its local cache instances// Multiple supervisors can monitor the same critical process
// Notification cost stays constant regardless of supervisor count// Price feed publishes thousands of updates per second
// Cost per update: one message per subscriber NODE
// Not one message per subscriber PROCESS// Same node, same remote target
processA.MonitorPID(remoteTarget) // Network round-trip
processB.MonitorPID(remoteTarget) // Instant (shared)
processC.MonitorPID(remoteTarget) // Instant (shared)// Different remote targets
processA.MonitorPID(remoteTarget1) // Network round-trip
processB.MonitorPID(remoteTarget2) // Network round-trip (different target)
// Different nodes subscribing to same target
// (Each node has its own subscription to the target)
nodeX_process.MonitorPID(remoteTarget) // Network round-trip
nodeY_process.MonitorPID(remoteTarget) // Network round-trip (from different node)// Producer creates event with 100-message buffer
token, _ := process.RegisterEvent("prices", gen.EventOptions{
Buffer: 100,
})
// Producer publishes messages over time
process.SendEvent("prices", token, msg1) // Stored in buffer
process.SendEvent("prices", token, msg2) // Stored in buffer
// ... more messages ...// Subscriber joins and receives buffer
buffered, _ := process.MonitorEvent(event)
for _, msg := range buffered {
// These are recent messages published before subscription
}// Process 1 subscribes at 10:00:00
buffered1, _ := process1.MonitorEvent(event)
// Receives messages 1-100
// Producer publishes messages 101-150
// Process 2 subscribes at 10:00:30
buffered2, _ := process2.MonitorEvent(event)
// Must receive messages 51-150 (different from process 1!)// Good: Configuration updates, moderate subscribers
token, _ := process.RegisterEvent("config.updates", gen.EventOptions{
Buffer: 10, // Last 10 config changes
})
// Good: State snapshots for late joiners
token, _ := process.RegisterEvent("game.state", gen.EventOptions{
Buffer: 1, // Just the latest state
})
// Better without buffer: High-frequency price feed
token, _ := process.RegisterEvent("prices.realtime", gen.EventOptions{
Buffer: 0, // Full sharing optimization
})
// Better without buffer: High subscriber count
token, _ := process.RegisterEvent("system.metrics", gen.EventOptions{
Buffer: 0, // Thousands of subscribers, skip buffer overhead
})token, _ := process.RegisterEvent("expensive.data", gen.EventOptions{
Notify: true,
})func (p *Producer) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case gen.MessageEventStart:
// First subscriber appeared
// Subscriber count: 0 → 1
p.Log().Info("First subscriber for event: %s", msg.Name)
case gen.MessageEventStop:
// Last subscriber left
// Subscriber count: 1 → 0
p.Log().Info("No more subscribers for event: %s", msg.Name)
}
return nil
}type PriceFeeder struct {
act.Actor
token gen.Ref
polling bool
}
func (p *PriceFeeder) Init(args ...any) error {
// Register event with notifications enabled
token, err := p.RegisterEvent("prices", gen.EventOptions{
Notify: true,
})
if err != nil {
return err
}
p.token = token
// Don't start polling yet - wait for subscribers
return nil
}
func (p *PriceFeeder) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case gen.MessageEventStart:
// Someone wants prices - start polling external API
p.Log().Info("Starting price polling - subscribers appeared")
p.polling = true
p.schedulePoll()
case gen.MessageEventStop:
// Nobody wants prices - stop wasting resources
p.Log().Info("Stopping price polling - no subscribers")
p.polling = false
case pollTick:
if p.polling {
price := p.fetchPriceFromAPI()
p.SendEvent("prices", p.token, price)
p.schedulePoll()
}
}
return nil
}
func (p *PriceFeeder) schedulePoll() {
p.SendAfter(p.PID(), pollTick{}, time.Second)
}// Producer on node A
token, _ := process.RegisterEvent("data", gen.EventOptions{Notify: true})
// Subscriber on node B subscribes
// Producer receives MessageEventStart
// Subscriber on node B unsubscribes (and was the only subscriber)
// Producer receives MessageEventStoptoken1, _ := process.RegisterEvent("prices.stocks", gen.EventOptions{Notify: true})
token2, _ := process.RegisterEvent("prices.crypto", gen.EventOptions{Notify: true})
// MessageEventStart for "prices.stocks" when first stock subscriber appears
// MessageEventStart for "prices.crypto" when first crypto subscriber appears
// These are independent - one can have subscribers while other doesn't// You subscribed to this process
process.MonitorPID(target)
// Target terminates for any reason
// - Returns error from callback
// - Panics
// - Receives kill signal
// - Node shuts down
// You receive notification
case gen.MessageDownPID:
// Subscription is automatically removed
// No cleanup needed on your part// You created several subscriptions
process.MonitorPID(target1)
process.MonitorPID(target2)
process.MonitorEvent(event)
// Your process terminates
// All subscriptions are automatically removed
// No cleanup code needed// Producer registered an event
token, _ := producer.RegisterEvent("prices", gen.EventOptions{Buffer: 100})
// Producer terminates (or explicitly calls UnregisterEvent)
// All subscribers receive termination notification:
// - Links receive MessageExitEvent
// - Monitors receive MessageDownEvent
// Event resources are cleaned up:
// - Buffer memory freed
// - Event name available for re-registration// You have subscriptions to targets on node B
process.MonitorPID(pid_on_nodeB)
process.MonitorProcessID(name_on_nodeB)
process.MonitorEvent(event_on_nodeB)
// Connection to node B fails
// (Network partition, node crash, etc.)
// You receive termination for ALL subscriptions to that node:
case gen.MessageDownPID:
if msg.Reason == gen.ErrNoConnection {
// Network failure, not process termination
}
case gen.MessageDownProcessID:
if msg.Reason == gen.ErrNoConnection {
// Network failure
}
case gen.MessageDownEvent:
if msg.Reason == gen.ErrNoConnection {
// Network failure
}// Remove link
process.UnlinkPID(target)
process.UnlinkEvent(event)
// Remove monitor
process.DemonitorPID(target)
process.DemonitorEvent(event)Service mesh sidecars for traffic management
API gateways for routing and load balancing
Health check endpoints and probes
Metrics exporters and tracing spans
Configuration management and secret injection
Each layer adds latency, complexity, and failure modes. A simple call between two services traverses client library, sidecar proxy, load balancer, another sidecar, server library. Each hop serializes, deserializes, and can fail independently.
Ergo eliminates these layers. Processes communicate directly through message passing. The framework handles serialization, routing, load balancing, and failure detection. No sidecars, no API gateways, no client libraries.
One network hop. One serialization. Built-in load balancing and failover. This isn't a philosophical difference - it's orders of magnitude less infrastructure to deploy, maintain, and debug.
Service discovery is the foundation of clustering. How does node A find node B? How does a process locate the right service instance? Ergo provides three registrar options, each suited for different scales and requirements.
The embedded registrar requires no external infrastructure. The first node on a host becomes the registrar server; others connect as clients.
Cross-host discovery uses UDP queries. When node 2 needs to reach node 4, it asks its local registrar server (node 1), which queries node 4's host via UDP.
Use for: Development, testing, single-host deployments, simple multi-host setups without firewalls blocking UDP.
Limitations: No application discovery, no configuration management. Node listing and membership events are host-scoped - they cover the nodes of this machine plus, for listing, the hosts of connected peers.
etcd provides centralized discovery with application routing, configuration management, and event notifications. Nodes register with etcd and maintain leases for automatic cleanup.
etcd registrar capabilities:
Node discovery
Find all nodes in the cluster
Application discovery
Find which nodes run specific applications
Weighted routing
Use for: Teams already running etcd, clusters up to 50-70 nodes, deployments needing application discovery.
Saturn is purpose-built for Ergo. Instead of polling (like etcd), it maintains persistent connections and pushes updates immediately. Topology changes propagate in milliseconds.
Saturn vs etcd:
Update propagation
Polling (seconds)
Push (milliseconds)
Connection model
HTTP requests
Use for: Large clusters, real-time topology awareness, production systems where discovery latency matters.
Applications are the unit of deployment in Ergo. A node can load multiple applications, start them with different modes, and register them with the registrar. Other nodes discover applications and route requests based on weights.
When you start an application, it automatically registers with the registrar (if using etcd or Saturn). The spec (including Weight) is returned from the application's Load callback:
The registrar now knows: application "api" is running on this node with weight 100.
Other nodes can discover where applications run:
Output might show:
Weights enable traffic distribution. A node with weight 100 receives twice as much traffic as a node with weight 50. Use this for:
Canary deployments: New version with weight 10, stable with weight 90
Capacity matching: Powerful nodes get higher weights
Graceful draining: Set weight to 0 before maintenance
During canary and rolling deployments, the cluster runs mixed code versions. Messages sent from new nodes must be understood by old nodes, and vice versa. Ensure your message types support version coexistence as described in Message Versioning.
Once you know where applications run, route requests using weighted selection:
This is application-level load balancing without external infrastructure. No load balancer service, no sidecar proxies.
Horizontal scaling means running the same application on multiple nodes. Each instance handles a portion of traffic. Add nodes to increase capacity; remove nodes to reduce costs.
Each client discovers all api instances and distributes requests based on weights.
On each worker node:
On coordinator/client nodes:
gen.RemoteNode, which is what GetNode returns, describes the peer rather than talks to it: it offers Spawn, ApplicationStart and Info, and has no Send or Call. Ordinary messaging goes through the process's own Send/Call with a gen.ProcessID, and the framework opens the connection if there is not one already.
Scale up: Start new node with the same application. It registers with the registrar. Other nodes discover it through events or next resolution.
Scale down: Set weight to 0 (drain), wait for in-flight work, stop the node. Registrar removes the registration when the lease expires.
Subscribe to registrar events to react when instances join or leave:
No polling. No service mesh. Events arrive within milliseconds (Saturn) or at the next poll cycle (etcd).
Failover means having standby instances ready to take over when the primary fails. The leader actor implements distributed leader election - exactly one instance is active (leader) while others wait (followers).
The leader.Actor from ergo.services/actor/leader implements Raft-based leader election. Embed it in your actor to participate in elections:
All instances start as followers
If no heartbeats arrive, a follower becomes candidate
Candidate requests votes from peers
Majority vote wins; candidate becomes leader
Leader sends periodic heartbeats
If leader fails, followers detect timeout and elect new leader
Failover happens automatically. No manual intervention. The surviving nodes elect a new leader within the election timeout (150-300ms by default).
Single-writer coordination: Only the leader writes to prevent conflicts.
Task scheduling: Only the leader runs periodic tasks.
Re-arming with SendAfter on every tick is the manual way to build a periodic timer. SendEvery(s.PID(), RunScheduledTasks{}, 10*time.Second) does the same with one reused timer (no per-tick allocation). Keep the returned CancelFunc and call it when the node loses leadership: a SendEvery ticker fires until it is cancelled or its process stops, so there is no per-tick IsLeader() guard to fall back on - you stop it explicitly instead.
Distributed locks: Leader grants exclusive access.
Leader election requires a majority (quorum) to prevent split-brain:
3 nodes
2
1
5 nodes
3
If a network partition splits 5 nodes into groups of 3 and 2:
The group of 3 can elect a leader (has quorum)
The group of 2 cannot (no quorum)
This prevents both sides from having leaders and making conflicting decisions.
The metrics actor from ergo.services/actor/metrics exposes Prometheus-format metrics. Base metrics are collected automatically; you add custom metrics for application-specific telemetry.
This starts an HTTP server at :9090/metrics with base metrics:
ergo_node_uptime_seconds
Node uptime
ergo_processes_total
Total process count
ergo_processes_running
Extend the metrics actor for application-specific telemetry:
Update metrics from your application:
Now you have cluster-wide visibility: process counts, memory usage, network traffic, custom business metrics - all in Prometheus/Grafana.
Observer is a web UI for cluster inspection. Run it as an application within your node or as a standalone tool.
Open http://localhost:9911 to see:
Node info: Uptime, memory, CPU, process counts
Network: Connected nodes, acceptors, traffic graphs
Process list: All processes with state, mailbox depth, runtime
Process details: Links, monitors, aliases, environment
Logs: Real-time log stream with filtering
There is no standalone observer binary. Observer is the embedded application above, and it does not need one: a single node running it reaches every other node in the cluster over the ordinary connection. Add it to one node - a dedicated one if you like - and inspect the others from there by name.
The tools that do exist install as ergo.tools/*: ergo (the scaffolder), saturn (the registrar server) and argus (an actor-model vet tool).
Observer calls HandleInspect on processes to get internal state:
This data appears in the Observer UI, updated every second.
Observer helps diagnose:
Memory leaks: Watch ergo_memory_alloc_bytes, find processes with growing mailboxes
Stuck processes: Check "Top Running" sort to find processes consuming CPU
Message backlogs: "Top Mailbox" shows processes falling behind
Network issues: Traffic graphs show bytes/messages per remote node
Process relationships: Links and monitors show supervision structure
Ergo supports starting processes and applications on remote nodes. This enables dynamic workload distribution and orchestration.
Start a process on a remote node:
The spawned process runs on the remote node but can communicate with any process in the cluster.
Start an application on a remote node:
Use this for:
Dynamic orchestration: Coordinator decides which apps run where
Staged deployment: Start apps in order, waiting for health checks
Capacity management: Start/stop apps based on load
etcd and Saturn registrars provide cluster-wide configuration with hierarchical overrides.
Node-specific overrides cluster-wide, which overrides global.
Values are stored as strings with type prefixes:
React to config changes in real-time:
No restart required. Configuration propagates to all nodes automatically.
Here's a complete example: a job processing cluster with load balancing, failover, metrics, and observability.
Load balancing: Jobs distribute across workers based on weights
Failover: If coordinator leader fails, another takes over in <300ms
Discovery: Workers auto-register; coordinators discover them via events
Metrics: Prometheus scrapes all nodes for cluster-wide visibility
Inspection: Observer UI shows processes, mailboxes, network traffic
Configuration: Update settings via etcd; changes propagate immediately
All of this with:
No API gateways
No service mesh
No load balancer services
No orchestration layers
No client libraries with retry logic
Just Ergo nodes communicating directly through message passing.
Ergo provides integrated technologies for building production clusters:
Registrars
Service discovery
ergo.services/registrar/etcd, registrar/saturn
Applications
Deployment units with weights
These components eliminate the integration layers that dominate traditional microservice architectures. Instead of building infrastructure, you build applications.
For implementation details, see:
// No configuration needed - embedded registrar is the default
node, _ := ergo.StartNode("service@localhost", gen.NodeOptions{})import "ergo.services/registrar/etcd"
options := gen.NodeOptions{
Network: gen.NetworkOptions{
Registrar: etcd.Create(etcd.Options{
Endpoints: []string{"etcd1:2379", "etcd2:2379", "etcd3:2379"},
Cluster: "production",
}),
},
}
node, _ := ergo.StartNode("service@host", options)import "ergo.services/registrar/saturn"
options := gen.NodeOptions{
Network: gen.NetworkOptions{
Registrar: saturn.Create("saturn.example.com", "your-token", saturn.Options{
Cluster: "production",
}),
},
}
node, _ := ergo.StartNode("service@host", options)type APIApp struct {
app.Application
}
func (a *APIApp) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "api",
Group: []gen.ApplicationMemberSpec{{Factory: createAPIHandler}},
Weight: 100, // higher weight = more traffic
}, nil
}
// In main:
node.ApplicationLoad(&APIApp{})
node.ApplicationStart("api", gen.ApplicationOptions{})routes, _ := node.Network().ResolveApplication("api")
for _, route := range routes {
fmt.Printf("api on %s (weight: %d, state: %s)\n",
route.Node, route.Weight, route.State)
}api on node1@host1 (weight: 100, state: running)
api on node2@host2 (weight: 100, state: running)
api on node3@host3 (weight: 50, state: running)// Canary deployment
// Stable nodes
stableSpec := gen.ApplicationSpec{Name: "api", Weight: 90}
// Canary node
canarySpec := gen.ApplicationSpec{Name: "api", Weight: 10}func (c *Client) selectNode(routes []gen.ApplicationRoute) gen.Atom {
// Filter running instances
var running []gen.ApplicationRoute
for _, r := range routes {
if r.State == gen.ApplicationStateRunning {
running = append(running, r)
}
}
// Weighted random selection
totalWeight := 0
for _, r := range running {
totalWeight += r.Weight
}
pick := rand.Intn(totalWeight)
cumulative := 0
for _, r := range running {
cumulative += r.Weight
if pick < cumulative {
return r.Node
}
}
return running[0].Node
}func main() {
options := gen.NodeOptions{
Network: gen.NetworkOptions{
Registrar: etcd.Create(etcd.Options{
Endpoints: []string{"etcd:2379"},
Cluster: "production",
}),
},
}
node, _ := ergo.StartNode("worker@"+hostname(), options)
// Load and start the application (spec returned from WorkerApp.Load)
node.ApplicationLoad(&WorkerApp{})
node.ApplicationStartPermanent("worker", gen.ApplicationOptions{})
node.Wait()
}func (c *Coordinator) distributeWork(job Job) error {
routes, _ := c.Node().Network().ResolveApplication("worker")
// Select node based on weights
targetNode := c.selectNode(routes)
// Messaging needs no connection handle: address the process by name and node
return c.Send(gen.ProcessID{Name: "worker_handler", Node: targetNode}, job)
}// Drain before shutdown
info, _ := node.ApplicationInfo("worker")
// Update weight through your deployment tooling
// Wait for in-flight work...
node.Stop()func (c *Coordinator) Init(args ...any) error {
registrar, _ := c.Node().Network().Registrar()
event, _ := registrar.Event()
c.MonitorEvent(event)
return nil
}
func (c *Coordinator) HandleEvent(event gen.MessageEvent) error {
switch msg := event.Message.(type) {
case etcd.EventApplicationStarted:
if msg.Name == "worker" {
c.Log().Info("worker started on %s (weight: %d)", msg.Node, msg.Weight)
c.refreshWorkerList()
}
case etcd.EventApplicationStopped:
if msg.Name == "worker" {
c.Log().Info("worker stopped on %s", msg.Node)
c.refreshWorkerList()
}
case etcd.EventNodeLeft:
c.Log().Warning("node left: %s", msg.Name)
c.handleNodeFailure(msg.Name)
}
return nil
}import "ergo.services/actor/leader"
type Scheduler struct {
leader.Actor
jobQueue []Job
active bool
}
func (s *Scheduler) Init(args ...any) (leader.Options, error) {
return leader.Options{
ClusterID: "scheduler-cluster",
Bootstrap: []gen.ProcessID{
{Name: "scheduler", Node: "node1@host1"},
{Name: "scheduler", Node: "node2@host2"},
{Name: "scheduler", Node: "node3@host3"},
},
}, nil
}
func (s *Scheduler) HandleBecomeLeader() error {
s.Log().Info("elected as leader - starting job processing")
s.active = true
s.startProcessingJobs()
return nil
}
func (s *Scheduler) HandleBecomeFollower(leaderPID gen.PID) error {
s.Log().Info("following leader: %s", leaderPID)
s.active = false
s.stopProcessingJobs()
return nil
}func (s *Scheduler) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case SubmitJob:
if s.IsLeader() == false {
// Forward to leader
s.Send(s.Leader(), msg)
return nil
}
s.jobQueue = append(s.jobQueue, msg.Job)
}
return nil
}func (s *Scheduler) HandleBecomeLeader() error {
s.SendAfter(s.PID(), RunScheduledTasks{}, 10*time.Second)
return nil
}
func (s *Scheduler) HandleMessage(from gen.PID, message any) error {
switch message.(type) {
case RunScheduledTasks:
if s.IsLeader() {
s.executeScheduledTasks()
s.SendAfter(s.PID(), RunScheduledTasks{}, 10*time.Second)
}
}
return nil
}func (s *Scheduler) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case AcquireLock:
if s.IsLeader() == false {
s.Send(from, NotLeader{Leader: s.Leader()})
return nil
}
if s.locks[msg.Resource] != nil {
s.Send(from, LockDenied{})
} else {
s.locks[msg.Resource] = &Lock{Holder: from, Expiry: time.Now().Add(msg.TTL)}
s.Send(from, LockGranted{})
}
}
return nil
}import "ergo.services/actor/metrics"
node.Spawn(metrics.Factory, gen.ProcessOptions{}, metrics.Options{
Host: "0.0.0.0",
Port: 9090,
CollectInterval: 10 * time.Second,
})type AppMetrics struct {
metrics.Actor
requestsTotal prometheus.Counter
requestLatency prometheus.Histogram
activeJobs prometheus.Gauge
registered bool
}
func (m *AppMetrics) Init(args ...any) (metrics.Options, error) {
m.requestsTotal = prometheus.NewCounter(prometheus.CounterOpts{
Name: "app_requests_total",
Help: "Total requests processed",
})
m.requestLatency = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "app_request_duration_seconds",
Buckets: prometheus.DefBuckets,
})
m.activeJobs = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "app_active_jobs",
Help: "Currently processing jobs",
})
return metrics.Options{Port: 9090}, nil
}
func (m *AppMetrics) CollectMetrics() error {
// Registry() is nil during Init - the actor creates the registry from the
// Options that Init returns. Register on the first collection instead.
if m.registered == false {
if err := m.Registry().Register(m.requestsTotal); err != nil {
return err
}
m.Registry().MustRegister(m.requestLatency, m.activeJobs)
m.registered = true
}
return nil
}// In your request handler
func (h *Handler) HandleMessage(from gen.PID, message any) error {
start := time.Now()
// Process request...
// Send metrics update
h.Send(metricsPID, RequestCompleted{Duration: time.Since(start)})
return nil
}
// In metrics actor
func (m *AppMetrics) HandleMessage(from gen.PID, message any) error {
switch msg := message.(type) {
case RequestCompleted:
m.requestsTotal.Inc()
m.requestLatency.Observe(msg.Duration.Seconds())
case JobStarted:
m.activeJobs.Inc()
case JobCompleted:
m.activeJobs.Dec()
}
return nil
}# prometheus.yml
scrape_configs:
- job_name: 'ergo-cluster'
static_configs:
- targets:
- 'node1:9090'
- 'node2:9090'
- 'node3:9090'import "ergo.services/application/observer"
options := gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
observer.CreateApp(observer.Options{
Host: "localhost",
Port: 9911,
}),
},
}
node, _ := ergo.StartNode("mynode@localhost", options)func (w *Worker) HandleInspect(from gen.PID, item ...string) map[string]string {
return map[string]string{
"queue_depth": fmt.Sprintf("%d", len(w.queue)),
"processed": fmt.Sprintf("%d", w.processedCount),
"current_job": w.currentJob.ID,
"uptime": time.Since(w.startTime).String(),
}
}// On remote node: enable spawn
network := node.Network()
network.EnableSpawn("worker", createWorker, "coordinator@host")
// On coordinator: spawn remotely
remote, _ := coordinator.Network().GetNode("worker@host")
pid, _ := remote.Spawn("worker", gen.ProcessOptions{}, WorkerConfig{BatchSize: 100})
// Send work to the remote process
coordinator.Send(pid, ProcessJob{Data: jobData})// On remote node: load app and enable remote start
node.ApplicationLoad(&WorkerApp{})
network.EnableApplicationStart("workers", "coordinator@host")
// On coordinator: start remotely
remote, _ := coordinator.Network().GetNode("worker@host")
remote.ApplicationStartPermanent("workers", gen.ApplicationOptions{})1. Node-specific in cluster: /cluster/{cluster}/config/{node}/{item}
2. Cluster-wide default: /cluster/{cluster}/config/*/{item}
3. Global default: /config/global/{item}# Set config via etcdctl
etcdctl put services/ergo/cluster/production/config/*/db.pool_size "int:20"
etcdctl put services/ergo/cluster/production/config/*/cache.enabled "bool:true"
etcdctl put services/ergo/cluster/production/config/node1/db.pool_size "int:50"// Read config in your application
registrar, _ := node.Network().Registrar()
config, _ := registrar.Config("db.pool_size", "cache.enabled")
poolSize := config["db.pool_size"].(int64) // 50 on node1, 20 on others
cacheEnabled := config["cache.enabled"].(bool) // truefunc (a *App) HandleEvent(event gen.MessageEvent) error {
switch msg := event.Message.(type) {
case etcd.EventConfigUpdate:
a.Log().Info("config changed: %s = %v", msg.Item, msg.Value)
switch msg.Item {
case "log.level":
a.updateLogLevel(msg.Value.(string))
case "cache.size":
a.resizeCache(msg.Value.(int64))
}
}
return nil
}package main
import (
"ergo.services/actor/leader"
"ergo.services/actor/metrics"
"ergo.services/application/observer"
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/registrar/etcd"
)
type Coordinator struct {
leader.Actor
workers []gen.ApplicationRoute
}
func (c *Coordinator) Init(args ...any) (leader.Options, error) {
// Subscribe to registrar events
reg, _ := c.Node().Network().Registrar()
ev, _ := reg.Event()
c.MonitorEvent(ev)
// Initial worker discovery
c.refreshWorkers()
return leader.Options{
ClusterID: "coordinators",
Bootstrap: []gen.ProcessID{
{Name: "coordinator", Node: "coord1@host1"},
{Name: "coordinator", Node: "coord2@host2"},
{Name: "coordinator", Node: "coord3@host3"},
},
}, nil
}
func (c *Coordinator) HandleBecomeLeader() error {
c.Log().Info("became leader - starting job distribution")
c.SendAfter(c.PID(), DistributeJobs{}, time.Second)
return nil
}
func (c *Coordinator) HandleBecomeFollower(leader gen.PID) error {
c.Log().Info("following %s", leader)
return nil
}
func (c *Coordinator) HandleEvent(event gen.MessageEvent) error {
switch event.Message.(type) {
case etcd.EventApplicationStarted, etcd.EventApplicationStopped:
c.refreshWorkers()
}
return nil
}
func (c *Coordinator) refreshWorkers() {
c.workers, _ = c.Node().Network().ResolveApplication("worker")
}
func main() {
options := gen.NodeOptions{
Network: gen.NetworkOptions{
Registrar: etcd.Create(etcd.Options{
Endpoints: []string{"etcd:2379"},
Cluster: "production",
}),
},
Applications: []gen.ApplicationBehavior{
observer.CreateApp(observer.Options{Port: 9911}),
},
}
node, _ := ergo.StartNode("coord1@host1", options)
// Start metrics
node.Spawn(metrics.Factory, gen.ProcessOptions{}, metrics.Options{Port: 9090})
// Start coordinator
node.SpawnRegister("coordinator", func() gen.ProcessBehavior {
return &Coordinator{}
}, gen.ProcessOptions{})
node.Wait()
}package main
import (
"ergo.services/actor/metrics"
"ergo.services/ergo"
"ergo.services/ergo/app"
"ergo.services/ergo/gen"
"ergo.services/registrar/etcd"
)
type WorkerApp struct {
app.Application
}
func (w *WorkerApp) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "worker",
Weight: 100,
Group: []gen.ApplicationMemberSpec{
{Name: "handler", Factory: createHandler},
},
}, nil
}
func main() {
options := gen.NodeOptions{
Network: gen.NetworkOptions{
Registrar: etcd.Create(etcd.Options{
Endpoints: []string{"etcd:2379"},
Cluster: "production",
}),
},
}
node, _ := ergo.StartNode("worker1@host1", options)
// Start metrics
node.Spawn(metrics.Factory, gen.ProcessOptions{}, metrics.Options{Port: 9090})
// Load and start worker application
node.ApplicationLoad(&WorkerApp{})
node.ApplicationStartPermanent("worker", gen.ApplicationOptions{})
node.Wait()
}How to Structure Projects Built with Ergo Framework
The same codebase can run as a monolith on your laptop or as distributed services across a data center. This flexibility comes from one principle: applications are the unit of composition. How you organize your project determines whether you can use this flexibility or fight against it.
This chapter covers project organization, message isolation patterns, deployment strategies, and evolution paths. The goal is a structure that supports both development simplicity and production scalability without code changes.
Ergo's network transparency means a process doesn't know if it's talking to a neighbor in the same node or a remote process across the network. The same Send() call works either way. But this only helps if your code is organized to take advantage of it.
Consider two deployment scenarios:
Development: All applications in one process for fast iteration.
Production: Applications distributed across nodes for scalability.
The application code is identical in both cases. Only the entry point changes - which applications start on which nodes.
This works because:
Applications are self-contained functional units
Messages define contracts between applications
The framework handles routing transparently
Your project structure must preserve these properties. Mix them up, and you lose deployment flexibility.
A well-structured project separates entry points from applications from shared code:
Each directory in cmd/ produces a different binary with a different deployment topology.
Monolith - everything together:
Distributed - each application on its own node:
The application code (apps/api, apps/worker) is identical. The entry point decides what runs where.
Each subdirectory in apps/ is a self-contained application. An application is:
A cohesive functional unit
Deployable independently
Composed of actors with a supervision tree
Communicating via messages
Application structure:
Application definition:
Applications should not import each other. If apps/api imports apps/worker, you've created a compile-time dependency that limits deployment flexibility.
When applications need to communicate, they need shared message types. The types/ directory holds these contracts:
Both apps/orders and apps/shipping can import types and call types.RegisterTypes(a.Node().Network()) from their Load callbacks. The callback signature is Load(args ...any) (gen.ApplicationSpec, error) - the node is not a parameter, it is reached through the embedded app.Application as a.Node(). This breaks the circular dependency while maintaining strong typing.
Non-actor code that multiple applications use goes in lib/:
Libraries must be:
Stateless - no global variables, no goroutines
Pure - same inputs produce same outputs
Actor-agnostic - no dependency on gen.Process
Libraries are safe to call from actor callbacks because they don't block or manage state.
Messages define contracts between actors. The visibility of message types controls who can send them and where they can travel. Ergo uses Go's export rules plus EDF serialization requirements to create four isolation levels.
Understanding these levels is critical for proper encapsulation.
Messages used only within a single application instance on one node.
Characteristics:
Type is unexported (scheduleTask)
Fields are unexported (taskID, not TaskID)
Cannot be imported by other packages
Use when:
Communication between actors in the same application
Messages never leave the local node
Implementation details that shouldn't be exposed
Messages between instances of the same application across nodes.
Characteristics:
Type is unexported (replicateState)
Fields are exported (Version, not version)
Cannot be imported by other packages
Use when:
Replication between application instances
Cluster-internal coordination
Messages that other applications shouldn't see
Messages between different applications on the same node.
Characteristics:
Type is exported (StatusQuery)
Fields are unexported (taskID, not TaskID)
CAN be imported by other packages
Use when:
Local service queries
Same-node optimization paths
Explicitly preventing network transmission
This level is intentionally restrictive. If someone tries to send StatusQuery to a remote node, serialization fails. The unexported fields act as a compile-time guard against accidental network use.
Messages that form public contracts between applications across the cluster.
Each consuming application registers the shared types from its Load callback:
Characteristics:
Type is exported (ProcessTask)
Fields are exported (TaskID)
CAN be imported by any package
Use when:
Public API between applications
Events that multiple applications subscribe to
Commands sent across application boundaries
Start with Level 1 (maximum restriction). Only increase visibility when needed:
Does another application need this message?
No → Keep type unexported (Level 1 or 2)
Yes → Export type (Level 3 or 4)
Does this message cross node boundaries?
Applications typically have a supervision tree:
Applications accept configuration through an Options struct:
Entry points configure options based on deployment:
Applications discover each other through application names, not node names:
When running as monolith, routes returns the local node. When distributed, it returns remote nodes. The code doesn't change.
Applications publish events for loose coupling:
Events decouple applications. Orders doesn't know who listens. Shipping doesn't know where Orders runs.
Two things the shapes above are not interchangeable about. RegisterEvent makes this process the producer and returns a gen.Ref token that SendEvent demands; a consumer never calls it. And a subscription returns the buffered events, which is how a late subscriber catches up - dropping that slice silently loses everything published before it arrived. The Init-time link also has an ordering hazard: if the producer has not registered yet, the link fails with gen.ErrEventUnknown, so a consumer that must not miss the start needs to retry rather than assume. See for the full model.
Everything in one process for fast iteration:
Benefits:
Single binary to run
No network setup
Easy debugging
Fast startup
Each application on dedicated nodes:
Each binary runs one application:
Benefits:
Independent scaling per tier
Fault isolation
Resource optimization
Zero-downtime updates
Group related applications for efficiency:
Benefits:
Reduced network hops for common paths
Fewer nodes to manage
Right-sized for actual traffic patterns
Test actors in isolation using the testing framework:
unit.Spawn runs the actor's Init on a mock node and hands back a *unit.Subject: SendMessage, Call and their name/alias variants drive it, and the Should* family asserts on what it did - every outbound operation is recorded rather than performed. Use unit.Prepare instead when the actor does something at Init time that has to be stubbed first.
Test complete applications:
Test multiple nodes:
Two things this shape is not free to change. A node name is <name>@<host> and nothing else - only the name half is character-checked at start, and the host half goes to the acceptor as written, so worker@localhost:15002 is resolved as a hostname and the node never comes back. And gen.RemoteNode is about the peer, not about talking to it: it offers Spawn, ApplicationStart and Info, so a request to a process on that node is made from a process with Call(gen.ProcessID{Name: "queue_manager", Node: "worker@localhost"}, ...). For multi-node tests over the real protocol, ergo/testing/stage is the harness built for exactly this - see .
Begin with a monolith:
When the monolith grows, extract bounded contexts:
Step 1: Identify boundaries in the combined application.
Step 2: Create separate application packages.
Step 3: Update the entry point.
Step 4: When ready, create distributed entry points.
The application code never changes. Only entry points and deployment.
If you over-distributed:
No application code changes. Just different composition.
Do:
One application per bounded context
Applications that scale together can be one application
Applications that deploy together can be one application
Don't:
Create applications for single actors
Split applications by technical layer (web/service/data)
Create circular dependencies between applications
Good:
Bad:
Do:
Start with Level 1 (most restrictive)
Increase visibility only when needed
Document which level each message uses
Register Level 4 types with EDF
Don't:
Default to Level 4 for everything
Mix isolation levels arbitrarily
Use any or interface{} for messages
Do:
Applications import types/ for shared contracts
Applications import lib/ for utilities
Entry points import applications
Don't:
Applications import other applications
Libraries depend on applications
Create import cycles
Do:
Use Options structs for application config
Validate in CreateApp or Load
Provide sensible defaults
Read environment in entry points
Don't:
Hard-code configuration in actors
Read os.Getenv directly in actors
Store configuration in global variables
This article covered project organization for flexible deployment. As your system grows into a distributed cluster, two topics become essential:
- service discovery, load balancing, failover, and observability
- evolving message contracts during rolling upgrades
Load balance based on application weights
Configuration
Hierarchical config with type conversion
Events
Real-time notifications of cluster changes
Persistent TCP
Scalability
50-70 nodes
Thousands of nodes
Event latency
Next poll cycle
Immediate
Infrastructure
General-purpose KV store
Purpose-built for Ergo
2
7 nodes
4
3
Actively processing
ergo_memory_used_bytes
Memory from OS
ergo_memory_alloc_bytes
Heap allocation
ergo_connected_nodes_total
Remote connections
ergo_remote_messages_in_total
Messages received per node
ergo_remote_messages_out_total
Messages sent per node
ergo_remote_bytes_in_total
Bytes received per node
ergo_remote_bytes_out_total
Bytes sent per node
Core framework
Leader Actor
Failover via leader election
ergo.services/actor/leader
Metrics Actor
Prometheus observability
ergo.services/actor/metrics
Observer
Web UI, API and MCP surface for inspection
ergo.services/application/observer
Remote Spawn
Dynamic process creation
Core framework
Remote App Start
Dynamic application deployment
Core framework
Configuration
Hierarchical config management
Registrar feature
Cannot be serialized for network transmission
Maximum encapsulation
CAN be serialized (EDF requires exported fields)
Internal to application, but network-capable
Cannot be serialized (unexported fields block EDF)
Cross-application but local-only
Full network transparency
No
No
2
Same app, any node
unexported
Exported
Yes
No
3
Cross-app, same node
Exported
unexported
No
Yes
4
Everywhere
Exported
Exported
Yes
Yes
No → Keep fields unexported (Level 1 or 3)
Yes → Export fields (Level 2 or 4)
1
Within app, same node
unexported
unexported
project/
├── cmd/ # Entry points
│ ├── monolith/main.go # All apps together
│ ├── api/main.go # API node
│ ├── worker/main.go # Worker node
│ └── storage/main.go # Storage node
│
├── apps/ # Application packages
│ ├── api/
│ │ ├── app.go # Application definition
│ │ ├── handler.go # Request handling actor
│ │ ├── router.go # Routing logic
│ │ ├── messages.go # Internal messages
│ │ └── supervisor.go # Supervision tree
│ ├── worker/
│ │ ├── app.go
│ │ ├── processor.go
│ │ ├── queue.go
│ │ ├── messages.go
│ │ └── types.go
│ └── storage/
│ ├── app.go
│ ├── reader.go
│ ├── writer.go
│ ├── messages.go
│ └── types.go
│
├── types/ # Service-level contracts
│ ├── events.go # Cross-application events
│ └── commands.go # Cross-application commands
│
├── lib/ # Shared non-actor code
│ ├── config/ # Configuration utilities
│ └── models/ # Domain models
│
└── go.mod// cmd/monolith/main.go
package main
import (
"myproject/apps/api"
"myproject/apps/worker"
"myproject/apps/storage"
"ergo.services/ergo"
"ergo.services/ergo/gen"
)
func main() {
node, _ := ergo.StartNode("app@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
api.CreateApp(api.Options{Port: 8080}),
worker.CreateApp(worker.Options{Concurrency: 10}),
storage.CreateApp(storage.Options{DSN: "postgres://..."}),
},
})
node.Wait()
}// cmd/api/main.go
package main
import (
"myproject/apps/api"
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/registrar/etcd"
)
func main() {
node, _ := ergo.StartNode("api@api-server-1", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
api.CreateApp(api.Options{Port: 8080}),
},
Network: gen.NetworkOptions{
Registrar: etcd.Create(etcd.Options{
Endpoints: []string{"etcd:2379"},
Cluster: "production",
}),
},
})
node.Wait()
}// cmd/worker/main.go
package main
import (
"myproject/apps/worker"
"ergo.services/ergo"
"ergo.services/ergo/gen"
"ergo.services/registrar/etcd"
)
func main() {
node, _ := ergo.StartNode("worker@worker-1", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
worker.CreateApp(worker.Options{Concurrency: 50}),
},
Network: gen.NetworkOptions{
Registrar: etcd.Create(etcd.Options{
Endpoints: []string{"etcd:2379"},
Cluster: "production",
}),
},
})
node.Wait()
}apps/worker/
├── app.go # Application behavior implementation
├── processor.go # Main processing actor
├── queue.go # Queue management actor
├── supervisor.go # Supervision strategy
├── messages.go # Message types (see isolation levels)
└── types.go # Domain types used in messages// apps/worker/app.go
package worker
import "ergo.services/ergo/gen"
type Options struct {
Concurrency int
QueueSize int
}
func CreateApp(opts Options) gen.ApplicationBehavior {
return &app{options: opts}
}
type app struct {
app.Application
options Options
}
func (a *app) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "worker",
Description: "Background task processing",
Weight: 100,
Group: []gen.ApplicationMemberSpec{
{Name: "queue", Factory: a.createQueue},
{Name: "supervisor", Factory: a.createSupervisor},
},
Env: map[gen.Env]any{
"CONCURRENCY": a.options.Concurrency,
"QUEUE_SIZE": a.options.QueueSize,
},
}, nil
}// types/events.go
package types
import (
"time"
"ergo.services/ergo/gen"
)
// Events published by the orders application
type OrderCreated struct {
OrderID string
CustomerID string
Total int64
CreatedAt time.Time
}
type OrderCompleted struct {
OrderID string
CompletedAt time.Time
}
// Helper that consumers call from their application's Load() callback.
func RegisterTypes(network gen.Network) error {
return network.RegisterTypes([]any{
OrderCreated{},
OrderCompleted{},
})
}// lib/config/config.go
package config
import "os"
func DatabaseURL() string {
return os.Getenv("DATABASE_URL")
}
// lib/models/order.go
package models
type Order struct {
ID string
CustomerID string
Items []OrderItem
Total int64
}// apps/worker/messages.go
package worker
// Unexported type, unexported fields
// Cannot be referenced outside this package
// Cannot be serialized (unexported fields)
type scheduleTask struct {
taskID string
priority int
data []byte
}
type taskCompleted struct {
taskID string
result []byte
}// apps/worker/messages.go
package worker
// Unexported type, EXPORTED fields
// Cannot be referenced outside this package
// CAN be serialized (exported fields)
type replicateState struct {
Version int64 // Exported for EDF
TaskIDs []string
Positions map[string]int
}
type syncRequest struct {
FromVersion int64
ToVersion int64
}// apps/worker/messages.go
package worker
// EXPORTED type, unexported fields
// CAN be referenced by other packages
// Cannot be serialized (unexported fields)
type StatusQuery struct {
taskID string // unexported - prevents network use
}
type StatusResponse struct {
taskID string
status string
progress int
}// types/commands.go
package types
// EXPORTED type, EXPORTED fields
// CAN be referenced by any package
// CAN be serialized
type ProcessTask struct {
TaskID string
Priority int
Payload []byte
}
type TaskResult struct {
TaskID string
Status string
Output []byte
Error string
}// apps/worker/app.go
package worker
import (
"ergo.services/ergo/gen"
"myapp/types"
)
func (a *Worker) Load(args ...any) (gen.ApplicationSpec, error) {
err := a.Node().Network().RegisterTypes([]any{
types.ProcessTask{},
types.TaskResult{},
})
if err != nil {
return gen.ApplicationSpec{}, err
}
return gen.ApplicationSpec{ /* ... */ }, nil
}// apps/worker/app.go
func (a *app) Load(args ...any) (gen.ApplicationSpec, error) {
return gen.ApplicationSpec{
Name: "worker",
Group: []gen.ApplicationMemberSpec{
{Name: "queue_manager", Factory: createQueueManager},
{
Name: "processor_sup",
Factory: createProcessorSupervisor,
Args: []any{a.options.Concurrency},
},
},
}, nil
}
// apps/worker/supervisor.go
type processorSupervisor struct {
act.Supervisor
}
func (s *processorSupervisor) Init(args ...any) (act.SupervisorSpec, error) {
concurrency := args[0].(int)
children := make([]act.SupervisorChildSpec, concurrency)
for i := 0; i < concurrency; i++ {
children[i] = act.SupervisorChildSpec{
Name: gen.Atom(fmt.Sprintf("processor_%d", i)),
Factory: createProcessor,
}
}
return act.SupervisorSpec{
Type: act.SupervisorTypeOneForOne,
Children: children,
Restart: act.SupervisorRestart{
Strategy: act.SupervisorStrategyTemporary,
},
}, nil
}
func createProcessorSupervisor() gen.ProcessBehavior {
return &processorSupervisor{}
}// apps/worker/app.go
type Options struct {
Concurrency int
QueueSize int
RetryAttempts int
RetryDelay time.Duration
}
func DefaultOptions() Options {
return Options{
Concurrency: 10,
QueueSize: 1000,
RetryAttempts: 3,
RetryDelay: time.Second,
}
}
func CreateApp(opts Options) gen.ApplicationBehavior {
// Validate options
if opts.Concurrency < 1 {
opts.Concurrency = DefaultOptions().Concurrency
}
return &app{options: opts}
}// cmd/worker/main.go
func main() {
opts := worker.Options{
Concurrency: getEnvInt("WORKER_CONCURRENCY", 50),
QueueSize: getEnvInt("WORKER_QUEUE_SIZE", 10000),
RetryAttempts: getEnvInt("WORKER_RETRY_ATTEMPTS", 5),
}
node, _ := ergo.StartNode("worker@host", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
worker.CreateApp(opts),
},
})
node.Wait()
}// apps/api/handler.go
func (h *Handler) processRequest(req Request) error {
// Discover worker application
routes, _ := h.Node().Network().ResolveApplication("worker")
if len(routes) == 0 {
return errors.New("no workers available")
}
// Select a worker (weighted random)
target := h.selectRoute(routes)
// Address the process by name and node: works whether local or remote,
// and needs no connection handle
result, err := h.Call(gen.ProcessID{Name: "queue_manager", Node: target.Node},
types.ProcessTask{
TaskID: req.ID,
Payload: req.Data,
})
return err
}// apps/orders/manager.go
type Manager struct {
act.Actor
completed gen.Ref // the token RegisterEvent handed back
}
func (m *Manager) Init(args ...any) error {
// The producer registers the event and keeps the token
token, err := m.RegisterEvent("order.completed", gen.EventOptions{Buffer: 16})
if err != nil {
return err
}
m.completed = token
return nil
}
func (m *Manager) completeOrder(orderID string) error {
// ... complete order logic ...
// Publish (Level 4 - service-level). Only the token holder can.
return m.SendEvent("order.completed", m.completed, types.OrderCompleted{
OrderID: orderID,
CompletedAt: time.Now(),
})
}
// apps/shipping/listener.go
func (l *Listener) Init(args ...any) error {
// The consumer subscribes by event name, not by token
buffered, err := l.LinkEvent(gen.Event{Name: "order.completed"})
if err != nil {
// gen.ErrEventUnknown when the producer has not registered yet
return err
}
for _, event := range buffered {
l.handle(event)
}
return nil
}
func (l *Listener) HandleEvent(event gen.MessageEvent) error {
l.handle(event)
return nil
}
func (l *Listener) handle(event gen.MessageEvent) {
switch e := event.Message.(type) {
case types.OrderCompleted:
l.createShipment(e.OrderID)
}
}// cmd/dev/main.go
func main() {
node, _ := ergo.StartNode("dev@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
api.CreateApp(api.Options{Port: 8080}),
worker.CreateApp(worker.Options{Concurrency: 5}),
storage.CreateApp(storage.Options{DSN: "dev.db"}),
observer.CreateApp(observer.Options{Port: 9911}),
},
})
node.Log().Info("Development server: http://localhost:8080")
node.Log().Info("Observer UI: http://localhost:9911")
node.Wait()
}// cmd/api/main.go
node, _ := ergo.StartNode("api@api-1", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
api.CreateApp(api.Options{Port: 8080}),
},
Network: gen.NetworkOptions{
Registrar: etcd.Create(etcd.Options{
Endpoints: []string{"etcd:2379"},
Cluster: "production",
}),
},
})// cmd/frontend/main.go - API + lightweight worker
node, _ := ergo.StartNode("frontend@web-1", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
api.CreateApp(api.Options{Port: 8080}),
worker.CreateApp(worker.Options{Concurrency: 5}), // light tasks
},
Network: gen.NetworkOptions{Registrar: registrar},
})
// cmd/backend/main.go - Heavy processing + storage
node, _ := ergo.StartNode("backend@compute-1", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
worker.CreateApp(worker.Options{Concurrency: 100}), // heavy tasks
storage.CreateApp(storage.Options{DSN: "prod.db"}),
},
Network: gen.NetworkOptions{Registrar: registrar},
})// apps/worker/processor_test.go
package worker
import (
"testing"
"ergo.services/ergo/gen"
"ergo.services/ergo/testing/check"
"ergo.services/ergo/testing/unit"
)
func TestProcessorHandlesTask(t *testing.T) {
s, err := unit.Spawn(t, createProcessor, gen.ProcessOptions{})
check.NoError(t, err)
// Deliver an internal message (Level 1)
s.SendMessage(gen.PID{}, scheduleTask{
taskID: "task-1",
priority: 1,
data: []byte("test"),
})
// Assert on what the actor did, not on what it holds
s.ShouldSend().Message(taskCompleted{
taskID: "task-1",
result: []byte("processed"),
}).Once().Assert()
}// apps/worker/integration_test.go
package worker_test
import (
"testing"
"myproject/apps/worker"
"ergo.services/ergo"
"ergo.services/ergo/gen"
)
func TestWorkerApplication(t *testing.T) {
node, err := ergo.StartNode("test@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
worker.CreateApp(worker.Options{Concurrency: 2}),
},
})
if err != nil {
t.Fatal(err)
}
defer node.Stop()
// Verify application started
info, err := node.ApplicationInfo("worker")
if err != nil {
t.Fatal(err)
}
if info.State != gen.ApplicationStateRunning {
t.Fatalf("expected running, got %s", info.State)
}
// Send test message and verify behavior
// ...
}func TestCrossNodeCommunication(t *testing.T) {
// A port belongs in the acceptor, never in the node name: the name is
// <name>@<host>, and a colon in it is passed to the resolver as part of
// the host, which fails with "no such host".
apiNode, err := ergo.StartNode("api@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
api.CreateApp(api.Options{}),
},
Network: gen.NetworkOptions{
Cookie: "test",
Acceptors: []gen.AcceptorOptions{{Host: "localhost", Port: 15001}},
},
})
if err != nil {
t.Fatal(err)
}
defer apiNode.Stop()
workerNode, err := ergo.StartNode("worker@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
worker.CreateApp(worker.Options{}),
},
Network: gen.NetworkOptions{
Cookie: "test",
Acceptors: []gen.AcceptorOptions{{Host: "localhost", Port: 15002}},
},
})
if err != nil {
t.Fatal(err)
}
defer workerNode.Stop()
// Without a registrar, tell this node where the peer listens
if err := apiNode.Network().AddRoute("worker@localhost", gen.NetworkRoute{
Route: gen.Route{Host: "localhost", Port: 15002},
}, 100); err != nil {
t.Fatal(err)
}
// A cross-node Call is made by a process, not by the node: spawn one and
// let it address the peer as gen.ProcessID{Name, Node}.
pid, err := apiNode.Spawn(createProbe, gen.ProcessOptions{})
if err != nil {
t.Fatal(err)
}
_ = pid
}// cmd/main.go - Everything together
func main() {
node, _ := ergo.StartNode("app@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
combined.CreateApp(), // One big application
},
})
node.Wait()
}combined/
├── order_handler.go → apps/orders/
├── order_processor.go → apps/orders/
├── shipping_handler.go → apps/shipping/
├── shipping_tracker.go → apps/shipping/
└── ...// apps/orders/app.go
package orders
func CreateApp(opts Options) gen.ApplicationBehavior {
return &app{options: opts}
}
// apps/shipping/app.go
package shipping
func CreateApp(opts Options) gen.ApplicationBehavior {
return &app{options: opts}
}// cmd/main.go - Multiple applications, still one node
func main() {
node, _ := ergo.StartNode("app@localhost", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
orders.CreateApp(orders.Options{}),
shipping.CreateApp(shipping.Options{}),
},
})
node.Wait()
}// cmd/orders/main.go
func main() {
node, _ := ergo.StartNode("orders@orders-1", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
orders.CreateApp(orders.Options{}),
},
Network: gen.NetworkOptions{Registrar: registrar},
})
node.Wait()
}// Before: Two binaries
// cmd/orders/main.go → orders.CreateApp()
// cmd/shipping/main.go → shipping.CreateApp()
// After: One binary
// cmd/fulfillment/main.go
func main() {
node, _ := ergo.StartNode("fulfillment@host", gen.NodeOptions{
Applications: []gen.ApplicationBehavior{
orders.CreateApp(orders.Options{}),
shipping.CreateApp(shipping.Options{}),
},
})
node.Wait()
}apps/
├── orders/ # Complete order lifecycle
├── shipping/ # Complete shipping lifecycle
└── inventory/ # Complete inventory managementapps/
├── order_api/ # Just API handlers
├── order_service/ # Just business logic
├── order_repository/ # Just data access
└── order_events/ # Just eventsSSE provides unidirectional server-to-client streaming over HTTP. Unlike WebSocket bidirectional connections, SSE is designed for scenarios where server pushes updates to clients - live feeds, notifications, real-time dashboards.
The framework provides SSE meta-process implementation that integrates SSE connections with the actor model. Each connection becomes an independent actor addressable from anywhere in the cluster.
SSE connections need two capabilities:
HTTP streaming: Connection must keep HTTP response open and stream events to client. Standard HTTP handlers return immediately - SSE requires long-lived responses.
Asynchronous writing: Backend actors must be able to push events to the client at any time - notifications, updates, data changes from the actor system.
This is exactly what meta-processes solve. The SSE connection meta-process holds the HTTP response open. Actor Handler receives messages from backend actors and writes formatted SSE events to the response stream.
Two meta-processes work together:
SSE Handler: Implements http.Handler interface. When HTTP request arrives, sets SSE headers and spawns Connection meta-process. Returns after connection closes.
SSE Connection: Meta-process managing one SSE connection. Actor Handler receives messages from actors, formats them as SSE events, writes to HTTP response stream. Connection lives until client disconnects or error occurs.
For client-side connections:
SSE Client Connection: Meta-process connecting to external SSE endpoint. External Reader continuously reads SSE stream, parses events, sends them to application actors.
Use sse.CreateHandler to create handler meta-process:
Handler options:
ProcessPool: List of process names that will receive messages from SSE connections. When connection is established, handler round-robins across this pool to select which process handles this connection. If empty, connection sends to parent process.
Heartbeat: How long a stream may stay quiet before a keepalive is written, which is what stops proxies and load balancers from closing an idle connection. It is written only when nothing else was, and checked on a fixed tick, so a stream can stay silent for up to twice this value. Zero means 30 seconds; a negative value disables heartbeats.
HeartbeatEvent: The event name to send the keepalive under. Empty - the default - sends an SSE comment, which no client sees at all; naming it makes the keepalive visible to the clients subscribed to that name, and to no one else. It must be a single line: a CR or LF is refused when the handler is created.
Compression: Enables gzip for clients that advertise support for it. CompressionLevel is the trade-off when it is on, zero meaning the default level.
MetaOptions: How the meta process of a single connection is spawned. MailboxSize is the field that matters here: the writer at the far end is a socket, so a slow client makes that mailbox grow, and a bound is what keeps one stalled reader from eating memory.
Refusal: Answers a request the handler could not turn into a stream. Nil answers with plain text, which a caller speaking another protocol cannot read - set it if your endpoint has an error contract of its own.
When client connects:
HTTP request arrives with Accept: text/event-stream
Handler sets SSE response headers
Handler spawns Connection meta-process
Connection sends MessageConnect
During connection lifetime:
Server events: Application sends message -> Actor Handler formats and writes SSE event
Heartbeats: Periodic comment lines keep connection alive
Connection remains open until client disconnects
When client disconnects:
HTTP request context is cancelled
Connection sends MessageDisconnect to application
Meta-process terminates
HTTP handler returns
Four message types flow between connections and actors:
sse.MessageConnect: Sent when connection established.
Receive this to track new connections:
sse.MessageDisconnect: Sent when connection closes.
Receive this to clean up connection state:
sse.Message: Event to send to client (server) or received from server (client).
Send events to client:
Wire format for the above message:
sse.MessageLastEventID: Sent when client reconnects with Last-Event-ID header.
Handle reconnection to resume from last event:
SSE events follow a simple text format:
event: - Event type. Client listens with addEventListener("type", ...). Optional, defaults to "message".
id: - Event ID. Client sends as Last-Event-ID header on reconnect. Optional.
retry:
The sse.Message struct maps directly to this format. Multi-line data is handled automatically.
Create client-side SSE connections with sse.CreateConnection:
Connection options:
URL: SSE server endpoint. Use http:// or https:// scheme.
Process: Process name that will receive events from server. If empty, sends to parent process.
Headers: Custom HTTP headers for the request. Useful for authentication.
LastEventID: Initial Last-Event-ID header value for resuming from specific event.
ReconnectInterval: Default reconnection delay. Can be overridden by server's retry: field. Default 3 seconds.
Client connections receive the same message types. External Reader parses SSE stream and sends sse.Message to application:
Connection meta-processes have gen.Alias identifiers that work across the cluster. Any actor on any node can send events to any connection:
Network transparency makes every SSE connection addressable like any other actor. Backend logic scattered across cluster nodes can push updates to specific clients without intermediaries.
Handler accepts ProcessPool - list of process names to receive connection messages. Handler distributes connections across this pool using round-robin:
Connection 1 sends to "handler1", connection 2 to "handler2", connection 3 to "handler3", connection 4 to "handler1", etc. This distributes load across multiple handler processes.
Useful for scaling: spawn multiple handler processes, each managing subset of connections. Prevents single handler from becoming bottleneck.
Choose SSE when:
Server pushes updates to clients (notifications, live feeds, dashboards)
Clients only need to receive, not send through same connection
Working with proxies that may not support WebSocket
Want automatic reconnection with event replay
Choose WebSocket when:
True bidirectional communication needed
Binary data transfer required
Low latency in both directions critical
Connection blocks waiting for client disconnect
Actor Handler waits for backend messages
data: - Event payload. Can span multiple lines, each prefixed with data:. Required.
Empty line terminates event.
type WebService struct {
act.Actor
connections map[gen.Alias]bool
}
func (w *WebService) Init(args ...any) error {
w.connections = make(map[gen.Alias]bool)
// Create SSE handler
sseHandler := sse.CreateHandler(sse.HandlerOptions{
ProcessPool: []gen.Atom{"sse-handler"},
Heartbeat: 30 * time.Second,
})
// Spawn handler meta-process
_, err := w.SpawnMeta(sseHandler, gen.MetaOptions{})
if err != nil {
return err
}
// Register with HTTP mux
mux := http.NewServeMux()
mux.Handle("/events", sseHandler)
// Create web server
server, err := meta.CreateWebServer(meta.WebServerOptions{
Host: "localhost",
Port: 8080,
Handler: mux,
})
if err != nil {
return err
}
_, err = w.SpawnMeta(server, gen.MetaOptions{})
return err
}type MessageConnect struct {
ID gen.Alias // Connection meta-process identifier
RemoteAddr net.Addr // Client address
LocalAddr net.Addr // Server address
Request *http.Request // Original HTTP request
}func (h *Handler) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case sse.MessageConnect:
h.connections[m.ID] = true
h.Log().Info("Client connected: %s from %s", m.ID, m.RemoteAddr)
// Send welcome event
h.SendAlias(m.ID, sse.Message{
Event: "welcome",
Data: []byte("Connected successfully"),
})
}
return nil
}type MessageDisconnect struct {
ID gen.Alias // Connection meta-process identifier
}case sse.MessageDisconnect:
delete(h.connections, m.ID)
h.Log().Info("Client disconnected: %s", m.ID)type Message struct {
ID gen.Alias // Connection identifier
Event string // Event type (optional)
Data []byte // Event data (can be multi-line)
MsgID string // Event ID for reconnection (optional)
Retry int // Retry hint in milliseconds (optional)
}// Simple data event
h.SendAlias(connID, sse.Message{
Data: []byte("Hello, client!"),
})
// Named event with ID
h.SendAlias(connID, sse.Message{
Event: "update",
Data: []byte(`{"temperature": 23.5}`),
MsgID: "42",
})
// Broadcast to all connections
for connID := range h.connections {
h.SendAlias(connID, sse.Message{
Event: "broadcast",
Data: []byte("Server announcement"),
})
}event: update
id: 42
data: {"temperature": 23.5}
type MessageLastEventID struct {
ID gen.Alias // Connection identifier
LastEventID string // ID from client header
}case sse.MessageLastEventID:
h.Log().Info("Client reconnected, last event: %s", m.LastEventID)
// Send missed events since LastEventID
h.sendMissedEvents(m.ID, m.LastEventID)event: <event-type>
id: <event-id>
retry: <milliseconds>
data: <line1>
data: <line2>
func (c *Client) Init(args ...any) error {
conn := sse.CreateConnection(sse.ConnectionOptions{
URL: url.URL{Scheme: "http", Host: "server:8080", Path: "/events"},
Process: "event-handler",
Headers: http.Header{"Authorization": []string{"Bearer token"}},
LastEventID: "42",
ReconnectInterval: 5 * time.Second,
})
connID, err := c.SpawnMeta(conn, gen.MetaOptions{})
if err != nil {
return err
}
c.Log().Info("Connected to SSE server: %s", connID)
return nil
}func (h *EventHandler) HandleMessage(from gen.PID, message any) error {
switch m := message.(type) {
case sse.MessageConnect:
h.Log().Info("Connected to server")
case sse.Message:
h.Log().Info("Event: %s, Data: %s", m.Event, string(m.Data))
case sse.MessageDisconnect:
h.Log().Info("Disconnected from server")
}
return nil
}// Actor on node1 sends to connection on node2
actor.SendAlias(connectionAlias, sse.Message{
Event: "notification",
Data: []byte("Update from backend service"),
})sseHandler := sse.CreateHandler(sse.HandlerOptions{
ProcessPool: []gen.Atom{"handler1", "handler2", "handler3"},
})Direction
Bidirectional
Server to client only
Protocol
Upgrade to ws://
Standard HTTP streaming
Client to server
WriteMessage()
Not supported (use separate HTTP requests)
Browser support
Requires WebSocket API
Native EventSource API
Reconnection
Manual implementation
Built-in with Last-Event-ID
Binary data
Supported
Text only (base64 encode if needed)
Proxy support
May require configuration
Works through standard HTTP proxies