Writing a runtime service
The provider and hosted facets, and how to choose between them.
A runtime service is part of what the runtime is, rather than something a flow can do. No flow references it, and there is no YAML for it.
This page is about the Go extension point. Runtime Services in Core Concepts is the flow author's view of the same layer (the object store, cache, queues and topics); this page is about how those get supplied.
A service lives in runtime/services/<name> and implements one or both facets, and may bring CLI commands alongside either.
| Provider | Hosted | |
|---|---|---|
| Supplies | core.RuntimeServices | nothing; it runs |
| Selected by | RUNTIME_SERVICES_MODULE | nothing; always on when compiled in |
| Active at once | exactly one | all of them |
| Configured by | environment | CLI flags on octo run |
| Examples | standalone, k8s, api | observability |
The provider facet
A provider supplies the platform capability flows depend on: leader election, the object store, secrets, queues, topics, resources. Providers are module-selected, so exactly one is active and registration is conditional:
const Module = "standalone"
func init() {
services.Register(Module, New) // a no-op unless this module is selected
}
func New(ctx context.Context, opts services.Options) (core.RuntimeServices, error) {
// ...
}Every imported provider registers unconditionally and only the selected one wins. Build tags decide which providers a binary carries: runtime/octo/providers_k8s.go is behind //go:build k8s, so client-go and NATS stay out of the default binary. The tags are mutually exclusive.
There are three providers. standalone is the default build; k8s is Octo's own PaaS; and api delegates every capability to an HTTP server you implement, which is how Octo runs on Cloud Run or against a platform of your own (see The platform API).
Adding a capability is usually the wrong move
core.RuntimeServices is annotated //nolint:interfacebloat; widening it forces every provider to implement something most cannot. Use an optional side interface the caller type-asserts instead. core.LogShipper is the worked example: the k8s and api modules implement it to ship logs, the standalone module does not, and the caller asserts and nil-checks.
type LogShipper interface {
LogSink() slog.Handler // nil when this module ships no logs
}
// at the call site
if shipper, ok := svc.(core.LogShipper); ok {
if sink := shipper.LogSink(); sink != nil {
// ...
}
}The hosted facet
A hosted service runs for the life of the process. It starts before the first flow generation, stops after the last, and spans every --watch reload. Because it has no YAML, it configures itself from CLI flags.
func init() {
services.RegisterHosted(New()) // never a no-op
}
type Service struct{ /* ... */ }
func (s *Service) Name() string { return "observability" }
func (s *Service) Usage() string { return "..." }
func (s *Service) Flags(fs *flag.FlagSet) {
fs.BoolVar(&s.enabled, "observability", envBool("OCTO_OBSERVABILITY", true), "...")
// ... and one call per remaining flag: --observability-addr, --metrics,
// --metrics-blocks.
}
func (s *Service) Start(ctx context.Context, health *services.Health) error {
// bind the port here; must not block
}
func (s *Service) Stop(ctx context.Context) error { /* ... */ }Hosted services are not module-selected: every one compiled in runs. A binary chooses which it ships by blank import, and by build tag if it should be droppable (//go:build !no_observability).
Four things that are easy to get wrong
Make the flag's default the environment variable, so "flag wins when passed" falls out of flag for free and --help prints what is in effect. These are read from the process environment, not from a .env file, because flags are parsed before any config is loaded.
fs.StringVar(&s.addr, "observability-addr", envString("OCTO_OBSERVABILITY_ADDR", ":39999"), "...")Usage is not optional if you have flags; it is appended to octo --help.
Start must not block, and an error it returns fails the run, so bind ports and open files there. The observability service is the deliberate exception: a bind failure on its fixed admin port logs at error level and returns nil, so a second octo run on the same host still serves its flows.
Stop gets a live context that outlives cancellation of the run, so a graceful drain has somewhere to happen.
Readiness
Start receives the *services.Health gate the CLI drives through the lifecycle: starting, ready, reloading, draining, stopped. Read it; do not write it. The observability service turns it into /readyz. There is no liveness gate: a server answering at all is the answer.
Seeing the config
Implement the optional services.ConfigAware to be handed each generation's config:
func (s *Service) Configured(config types.Config) { /* ... */ }It is called once per generation, again on every --watch reload. A generation is not a fresh start: anything you accumulate must survive it. The observability service pre-creates each flow's metric series here without resetting a counter, since a reset would make rate() read a reload as a restart.
CLI commands
A module may bring subcommands, registered from the same init manifest:
func init() {
registerProvider()
registerCommands() // services.RegisterCommand(openAPICommand{}, ...)
}main.go dispatches any command it does not recognize through the registry, and the help page picks up each command's Usage(). Commands are not module-selected: one is available whenever its package is compiled in, whatever RUNTIME_SERVICES_MODULE says. octo verify-platform-api checks whether a server is ready, before anyone would set the variable that selects the module talking to it.
Registering from the module rather than from main.go makes a capability appear in a binary exactly when the thing that can act on it does: octo openapi prints the platform API contract, and a build with no api provider should not offer it.
Both facets
A module may implement both: register with services.Register and services.RegisterHosted.
See also
- Observability: the only hosted service today, and the worked example above.
- The platform API: the provider that delegates every capability to a server you implement.
- Clustering: what the k8s provider supplies and why.
- Runtime Services: the same layer from a flow author's side.