Topologies
Francis can run in two topologies. They differ only in where the data store and coordination live, not in how you write actors. Your actor code (factories, Invoke, Alarm, state, alarms) is identical in both, only the host setup changes.
Local (host/local) | Remote (host/remote) | |
|---|---|---|
| Data store | Embedded in each host | Owned by a standalone runtime |
| Placement / state / alarms | Coordinated through the shared data store, peer-to-peer | Coordinated by the runtime |
| Extra process to run | None | The runtime control plane (one or more replicas) |
| Worker process | Self-contained | Stateless, connects to the runtime |
| Typical use | Single-node apps, embedded use, development, small clusters (1-4 instances) | Larger clusters (5+ instances), separation of control plane from workers |
Local topology#
In the local topology, everything is embedded in your app. Each host carries its own data store (SQLite or PostgreSQL) and the hosts coordinate peer-to-peer: there is no separate control plane process.
flowchart LR
subgraph H1[Host / app instance 1]
A1[Actors]
D1[(Data store)]
end
subgraph H2[Host / app instance 2]
A2[Actors]
D2[(Data store)]
end
H1 <-->|mTLS peer invocations| H2You create a local host with host/local:
import "github.com/italypaleale/francis/host/local"
h, err := local.NewHost(
local.WithAddress("127.0.0.1:7571"),
local.WithSQLiteProvider(local.SQLiteProviderOptions{
ConnectionString: "data.db",
}),
local.WithRuntimePSKs([]byte("change-me-please")),
)Hosts that share the same data store and the same runtime PSK form a cluster: they discover each other, place actors, and forward invocations between themselves over mTLS.
Choose local when:
- You want the simplest possible deployment, just your app and a database.
- You’re running a single node, or a small cluster that can share a database. While there’s no hard limit, best to not exceed 4 replicas, and to avoid auto-scaling them.
- You’re developing or testing.
When several local hosts form a multi-node cluster, they must share a data store that all nodes can reach (typically PostgreSQL). With an embedded SQLite file, a host is effectively single-node — see providers .
Remote topology#
In the remote topology, a standalone runtime is the control plane. It owns the data store and coordinates placement, state, and alarms. Your workers are stateless actor hosts that connect to the runtime over WebTransport.
flowchart TD
subgraph RT[Runtime control plane]
D[(Data store)]
end
W1[Worker 1<br/>actors] -->|connect| RT
W2[Worker 2<br/>actors] -->|connect| RT
W1 <-->|mTLS peer invocations| W2The runtime is the cmd/runtime binary, configured by a YAML file — see Deploying the runtime
. Workers use host/remote:
import "github.com/italypaleale/francis/host/remote"
h, err := remote.NewHost(
remote.WithAddress("127.0.0.1:7571"),
remote.WithRuntimeAddresses("127.0.0.1:7400"),
remote.WithHostBootstrapPSK([]byte("host-bootstrap-psk")),
remote.WithPinnedCA(caPEM), // pin the cluster CA (recommended)
)The worker code is otherwise identical to the local example: you RegisterActor, get the Service(), and Run. Only the host construction differs.
Client-only hosts#
A process that needs to reach the cluster but shouldn’t host actors (a CLI command, an admin tool, a web frontend that only invokes actors) can join as a client-only host:
h, err := remote.NewHost(
remote.WithRuntimeAddresses("127.0.0.1:7400"),
remote.WithHostBootstrapPSK([]byte("host-bootstrap-psk")),
remote.WithPinnedCA(caPEM),
remote.WithClientOnly(),
)It connects, authenticates, and gets a Service() with the full set of operations (invocations, state, alarms, jobs), but it registers no actor type.
A client-only host runs no peer server and doesn’t need a reachable address of its own.
You still have to Run it and wait for Ready, because the operations go through its runtime session.
Choose remote when:
- You want to separate the control plane (placement, state, alarms) from your stateless workers.
- You’re running a larger cluster (5+ replicas) and/or using auto-scaling, and prefer a dedicated coordination tier.
- You want to scale workers independently of where state lives.
Because the runtime persists state to its database, actor state survives both a worker restart and a runtime restart.
Data store providers#
Both topologies persist state and alarms through a provider. The available providers are:
| Provider | Local option | Runtime config (provider.connectionString) | Notes |
|---|---|---|---|
| SQLite | WithSQLiteProvider | a file path, e.g. data.db | Best for single-node and development. Must not live on a networked filesystem (NFS/SMB). |
| PostgreSQL | WithPostgresProvider | postgres://… | For multi-node clusters that share one database. |
| In-memory | WithStandaloneMemoryProvider | memory | Non-durable, for tests and single-node ephemeral setups. State is lost on restart. |
| Standalone SQLite | WithStandaloneSQLiteProvider | standalone:data.db | Keeps data in memory and persists changes to SQLite. Supports one runtime replica. |
| Standalone PostgreSQL | WithStandalonePostgresProvider | standalone:postgres://… | Keeps data in memory and persists changes to PostgreSQL. Supports one runtime replica. |
The runtime infers the backend from provider.connectionString: postgres:// (or postgresql://) for PostgreSQL, memory for in-memory, and anything else as a SQLite file path or DSN. The standalone: prefix selects the corresponding standalone variant; standalone:memory and standalone:memory:// remain aliases for memory.
The local host standalone provider variants can wrap an existing database connection or open one from a connection string.
SQLite vs PostgreSQL for clusters: a single SQLite file can’t be shared safely across machines, so multi-node clusters should use PostgreSQL (or, in the remote topology, let the single runtime own a SQLite file while workers stay stateless).
Switching topologies#
Because actor code is topology-agnostic, moving from local to remote (or back) is a change to host setup only: swap host/local for host/remote (or vice versa) and adjust the construction options. Your factories, Invoke, Alarm, state, and alarm code don’t change.
Apps that decide their topology at runtime (for example from configuration) can hold the host as a host.Host, the interface both local.Host and remote.Host implement:
import (
"github.com/italypaleale/francis/host"
"github.com/italypaleale/francis/host/local"
"github.com/italypaleale/francis/host/remote"
)
func newHost(runtimeAddresses []string) (host.Host, error) {
if len(runtimeAddresses) == 0 {
return local.NewHost( /* … */ )
}
return remote.NewHost(
remote.WithRuntimeAddresses(runtimeAddresses...),
// …
)
}The construction options stay in the local and remote packages, since they describe the topology itself.