Capy-Bearer Documentation

12. Services

A service is a unit that keeps running between requests. It owns I/O that a request handler cannot hold: child processes, pipes, sockets, timers. A request handler asks a service to do something by posting a message; the service answers through whatever channel fits, such as a WebSocket, the database, or the key-value cache. A pool of agents with their own processes, or a broker that keeps a connection open, is a service.

The shape of a service

Bearer owns the loop. Your code never blocks on a pipe or sleeps in a while. You write handlers, Bearer calls one for every event, each call is short and bounded, and the next event waits until the handler returns. This is the model of Erlang's gen_server and of Node's event loop. The SERVICE handler page walks through a complete service: a pool of child processes that takes jobs, frames their output, and replaces a child that dies.

Every service handler is a named SERVICE handler. The runtime's events are START, STOP, DATA, EXIT, and ACCEPT. Every other name is a signal your own code posts:

var ticks := 0

function SERVICE:START(props : any)
{
	ticks = 0
}

function SERVICE:TICK(props : any)
{
	ticks = ticks + 1
}

function RENDER()
{
	print("service unit ready\n")
}

Output

service unit ready

This unit is also an ordinary page. Its RENDER handler has a separate request workspace. It cannot read the running service's ticks. Use messages and application storage to communicate across that boundary. A service is identified by its unit path, and there is at most one running service per unit.

State lives in unit variables

A service keeps one workspace for its whole life, so a top-level var is initialized once, when the service starts, and keeps its value across every handler call. That is where service state belongs:

var agents := {}
var seen := 0

function SERVICE:START(props : any)
{
	agents.capacity = s64(props.count)
}

function SERVICE:DATA(props : any)
{
	seen = seen + 1
	agents.last = string(props.data)
}

function RENDER()
{
	print("state in unit variables\n")
}

Every handler binds request implicitly, as handlers always do. For a service it is a synthetic request with no session, body, or form, built once for the life of the service. It carries the start arguments in request.props and the unit path in request.unit, and it lets request builtins such as ws_send() and unit_call() work inside a service. Do not use it as a place for state.

Start, stop, send

A request handler, a task, or another service talks to a service through four calls. service_start(unit, props) records that the service should run and starts it; calling it on a running service does nothing. service_stop(unit) posts a STOP and the worker exits once SERVICE:STOP returns. service_status(unit) reports state, pid, started_at, restarts, mailbox, and stale. service_send(unit, signal, props, delay_ms) posts a message and returns at once:

print(service_send("/agents/pool.capy", "ASSIGN", job: "review 42"), "\n")

A send starts the service when it is not running, so a caller does not check liveness first. service_stop() therefore means stop now, not keep down: the next send brings the service back. A send returns false when the mailbox is full, the message is larger than one datagram, or the unit does not exist. Mailbox, spool, and state-storage failures can trap. True means acceptance, not handler completion. Signal names are case-sensitive and omit SERVICE:. A STOP signal calls stop directly and ignores the delay and props. The mailbox is the kernel's datagram queue, about 500 frames per service; messages the service holds for a delay are not counted against it. A send to a listening service is one datagram, and a request never waits for the service itself.

Start and stop also return before the service finishes the transition. A start on an already desired-running service does not update its start props. Read status to observe lifecycle state, not application job completion.

delay_ms is the one-shot timer. A service that posts to itself with a delay gets a retry or a backoff:

function SERVICE:START(props : any)
{
	print(service_send(string(request.unit), "RESPAWN", {}, 2000), "\n")
}

function SERVICE:RESPAWN(props : any)
{
	print("retry delivered\n")
}

Intervals

Inside a service, service_every(signal, interval_ms, props) delivers SERVICE:<signal> repeatedly. The loop owns the timer and re-arms it only after each delivery, so a slow handler stretches the interval instead of stacking ticks in the mailbox. One timer exists per signal name; calling it again replaces the interval, and an interval of zero cancels it.

function SERVICE:START(props : any)
{
	print(service_every("SWEEP", 30000), "\n")
}

function SERVICE:SWEEP(props : any)
{
	print("sweep\n")
}

Ports

A port is a file descriptor the service owns and the loop watches. port_spawn(argv, options) starts a child with its standard streams as pipes, port_connect(host, port) opens a TCP connection, and port_listen(path) listens on a unix socket. All three return a handle. port_write(handle, data) queues bytes without blocking, and port_close(handle) closes the port and sends a spawned child SIGTERM.

Readiness arrives as handlers. SERVICE:DATA brings port, stream, and whatever bytes were readable, at most one read per event, so line framing is the service's job. SERVICE:EXIT brings port and status after a child's last DATA. SERVICE:ACCEPT brings port and the accepted connection:

var buffers := {}

function SERVICE:DATA(props : any)
{
	var key := string(props.port)
	buffers[key] = string(buffers[key]) + string(props.data)
}

function SERVICE:EXIT(props : any)
{
	delete(buffers, string(props.port))
}

function RENDER()
{
	print("port handlers ready\n")
}

Port calls exist only inside a service worker, because a descriptor needs an owner that outlives one invocation and a loop that watches it. Anywhere else they trap.

The port_spawn API page has a complete Pi RPC service with cwd and env options, stdout framing, and exit handling.

Failure

Each handler call runs under the invocation deadline. A handler that hangs is interrupted, that one event is dropped with an operator log line, and the service continues. What a service prints goes to the operator log, since there is no response to write to.

A service that exits without a stop is crashed. The supervisor restarts it with increasing delays, SERVICE:START runs again, and restarts in the status counts it. This is Erlang's advice: let it crash, and make START able to rebuild what the service needs, including its ports and intervals. The crashed worker's in-memory messages, timers, and ports are gone. This is not a durable job queue. A handler trap drops one event without undoing earlier state changes. It does not by itself restart the service.

A service does not restart when its source changes. stale in the status says that the running workspace predates the source, so an admin page can offer a restart. Stop and start it, which recompiles the unit.

What a service is not

A service has no HTTP request, no session, and no response. Do not pass a request to a service; pass identifiers and small options, as you would to a task, and let the service read what it needs. Do not wait for a service in a page request. Post the message, and let the service report through a channel the page already watches.