spine-go
The primary Spine implementation, written in Go (github.com/poisnoir/spine-go). A node is a process; nodes exchange typed messages through two primitives — Services (RPC) and Pub/Sub — using Go generics instead of an IDL. Everything runs over local Unix domain sockets today; there is no network transport yet (see Spine Overview for what that does and doesn't mean in practice).
Installation
go get github.com/poisnoir/spine-go
Creating a node
Every program that talks to Spine starts by creating a Node. This registers with the spined daemon if one is reachable at /tmp/spine/spined — if not, the node silently falls back to "local-only mode" and keeps working (see Troubleshooting).
package main
import (
"context"
"log/slog"
"os"
"github.com/poisnoir/spine-go"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
ctx := context.Background()
node, err := spine.CreateNode("common", "my-node", ctx, logger)
if err != nil {
panic(err)
}
_ = node
}
The namespace argument matters: spined, as it exists today, only ever has one namespace, "common", created at boot. Registering under any other name gets rejected by the daemon (see Troubleshooting).
Pub/Sub
Publishers hold the latest value and push it to every connected subscriber; subscribers poll for the latest value rather than blocking on individual messages — if a subscriber is slow, it gets the newest value next time it asks, not a backlog.
pub, err := spine.NewPublisher[uint32](node, "temperature")
pub.Publish(37)
sub, err := spine.NewSubscriber[uint32](node, "temperature")
value, err := sub.Get() // blocks until the first value arrives, then returns the latest
K in NewPublisher[K]/NewSubscriber[K] can be any type MAD can encode — see MAD for what that currently means (no strings or slices).
Services (RPC)
Turn any Go function into a request/response endpoint. Two execution modes:
NewService— sequential. Requests queue and are handled one at a time by a single goroutine. Use this when the handler mutates shared state.NewThreadedService— each request is handled on its own goroutine. Use this for stateless or I/O-bound handlers (no shared state to protect).
handler := func(input string) (int, error) {
return len(input), nil
}
service, err := spine.NewService(node, "string_length", handler)
caller, err := spine.NewServiceCaller[string, int](node, "string_length")
result, err := caller.Call("hello spine", context.Background())
ServiceCaller and Subscriber both retry their underlying connection with exponential backoff (via github.com/cenkalti/backoff/v4) — a transient disconnect (e.g. the service process restarting) doesn't require the caller to be recreated.
Entities live as long as the node does
There's no Close() on Service, Publisher, Subscriber, or ServiceCaller — this was removed deliberately: an entity is meant to live as long as its node does, the same way a long-running HTTP server doesn't get told to "stop" individual handlers. If you need a topic/service to genuinely go away, that means ending the node's process (or its context) rather than tearing down one entity at a time. Real cleanup for Subscriber/ServiceCaller specifically — the two cases where it'd plausibly matter, since they represent momentary interest rather than a node's own identity — is planned, not bolted on.
What's actually happening on the wire
There's no service discovery step beyond the well-known filesystem paths Spine uses by convention:
/tmp/spine/service/<namespace>/<name>— aService's Unix socket/tmp/spine/publisher/<namespace>/<name>— aPublisher's Unix socket
A ServiceCaller/Subscriber just dials the path directly; spined is not consulted to resolve it. On connect, both sides exchange their MAD type Code() for the key and value types and refuse to proceed if they don't match — this is the only type-safety check that happens at runtime, since there's no shared IDL to check against ahead of time.
Links
- GitHub
- spine-zig — the wire-compatible Zig counterpart
- MAD — the serializer used for every message
- Spine Overview — how this fits together with
spined