Capacity, Drain & Health
Overview
Every Altruist server is a server node (IServerNode): it knows how much work it runs, whether it takes more, and how to stop without cutting anyone off. Three things come with it, with no code to write:
- Capacity — the work the server runs (rooms, worlds, …) adds up to a load, compared with a budget from
config.yml. A full server keeps running what it has and starts nothing new. - Drain — when the server is asked to stop (SIGTERM, a Kubernetes pod termination, an operator), it stops taking new work, lets the running work finish, and only then exits.
- Health and metrics — liveness, readiness and capacity endpoints for orchestrators and load balancers, and standard .NET metrics for autoscalers and dashboards.
Nothing here touches the network or the engine's hot path. Until you configure it, a server is unlimited and drains instantly when nothing is bound to it — existing apps behave exactly as before.
altruist:
server:
capacity:
max-load: 200 # the budget; 0 (default) = unlimited
drain:
timeout: 360 # seconds running work may take to finish (default 30)
force-grace: 5 # seconds stopped work gets after a timeout (default 5)
on-shutdown: true # drain when the host stops (default true)
token: "" # optional: allow POST /altruist/server/drain with this token
Capacity
Anything that runs on the server and costs something is an ICapacityContributor. It reports its units (how many rooms, worlds, …) and their load (in whatever unit max-load is counted in):
public interface ICapacityContributor
{
string Kind { get; } // "rooms", "worlds", ...
CapacitySample Sample(); // thread-safe and cheap: called from HTTP and metrics threads
}
Built-in contributors:
| Kind | Who | Load |
|---|---|---|
rooms | a RoomHost bound with UseServer(node) | IRoomGame.LoadOf(mode, playlist) per room (default 1, so max-load is a room count) |
worlds | the 2D/3D world organizers | altruist:server:capacity:world-load per world (default 0: worlds are counted, not budgeted) |
Register your own as a service ([Service(typeof(ICapacityContributor))]) or at run time with node.Register(contributor) (dispose the result to remove it).
The node sums them into a ServerCapacity report:
var c = node.Capacity();
c.State; // Starting | Ready | Full | Draining | Drained
c.Load; // summed load
c.MaxLoad; // the budget (0 = unlimited)
c.Free; // MaxLoad - Load, null when unlimited
c.Utilization; // Load / MaxLoad
c.Kinds; // units and load per kind
| State | Meaning |
|---|---|
Starting | starting up, or a required service (database, cache) is not connected (IServerStatus not alive) |
Ready | takes new work |
Full | its load reached max-load: running work goes on, nothing new starts |
Draining | going away: nothing new starts, the running work is let finish |
Drained | nothing runs any more (or the drain timed out and the rest was stopped) |
node.CanAccept(cost) is the admission check: true when the server is Ready and the cost still fits. Matchmaking and lobbies use it before opening a room; a room of a mode that does not fit waits in the queue (see Matchmaking).
Note:
Choose the load unit to match what limits your server. For match rooms, CPU per room is a good start: measure one room of each mode with the engine metrics and give heavier modes a higher LoadOf. A broken contributor (one that throws) counts as empty and is logged; it cannot take the report or readiness down.
Drain
Work that has to wind down is an IDrainParticipant:
public interface IDrainParticipant
{
string Kind { get; }
void BeginDrain(); // take no new work, let the running work finish (once, any thread)
bool IsDrained { get; } // nothing runs any more
void ForceStop(); // the drain timed out: stop what still runs (once at most)
}
node.DrainAsync(timeout):
- marks the server
Draining(CanAcceptis false from now on) and callsBeginDrainon every participant — also on ones that register while the drain runs; - waits until every participant is drained, up to
drain:timeout; - on a timeout, calls
ForceStopon the ones still running and waitsdrain:force-gracemore; - marks the server
Drainedand returns whether everything finished in time.
Every caller shares the same drain. Cancelling a caller only stops its wait. A participant that throws cannot hang the drain.
A room host bound with UseServer(node) is a participant: queues and lobbies close (or move to another server in a fleet), running rooms play to their end, rejoins still work, connections without a room are told the server is going away (IRoomGame.ServerDraining), and on a timeout the remaining rooms are closed (IRoomGame.OnForcedClose).
Drain on shutdown
ServerDrainOnShutdown drains the node in the host's StoppingAsync, before any hosted service stops — so the engine keeps stepping the rooms while they finish. The host's shutdown timeout is raised to cover drain:timeout + drain:force-grace. Keep your orchestrator's grace period above it:
spec:
terminationGracePeriodSeconds: 380 # > drain:timeout (360) + force-grace (5)
Endpoints
| Endpoint | Answer |
|---|---|
GET /altruist/server/live | 200 while the process runs (liveness probe) |
GET /altruist/server/ready | 200 only while Ready; 503 when starting, full, draining or drained (readiness probe: such a server leaves the load balancer, its running work goes on) |
GET /altruist/server/capacity | the ServerCapacity report (allocators, dashboards) |
POST /altruist/server/drain?wait=&timeoutSeconds= | starts the drain; with wait=true answers 200 once drained, 202 when it timed out. Allowed from loopback, or with the X-Altruist-Drain-Token header when drain:token is set |
{
"nodeId": "game-7f9c-1-3b2e...",
"state": "Ready",
"load": 37,
"maxLoad": 200,
"free": 163,
"utilization": 0.185,
"accepting": true,
"unlimited": false,
"kinds": [ { "kind": "rooms", "units": 31, "load": 37 } ]
}
livenessProbe:
httpGet: { path: /altruist/server/live, port: 8080 }
readinessProbe:
httpGet: { path: /altruist/server/ready, port: 8080 }
periodSeconds: 2
Metrics
Standard System.Diagnostics.Metrics instruments; export them with OpenTelemetry, Prometheus or watch them with dotnet-counters:
| Meter | Instrument | Meaning |
|---|---|---|
Altruist.Server | altruist.server.load | summed load |
altruist.server.max_load | the budget (0 = unlimited) | |
altruist.server.state | 0 starting, 1 ready, 2 full, 3 draining, 4 drained | |
altruist.server.accepting | 1 while it takes new work | |
altruist.server.units, altruist.server.kind_load | per kind (tag kind) | |
Altruist.Engine | altruist.engine.frame.duration | ms per engine frame |
altruist.engine.stepper.duration | ms per stepper and frame (tag stepper) | |
altruist.engine.fixed_steps, altruist.engine.overruns | fixed steps run; frames that hit max-steps-per-frame |
A Kubernetes HPA can scale on altruist.server.load / altruist.server.max_load (keep a margin of free rooms), and altruist.engine.overruns tells you a server is overloaded before players feel it.
dotnet-counters monitor -n MyGame.Server --counters Altruist.Server,Altruist.Engine
Configuration Reference
altruist:
server:
capacity:
max-load: 0 # budget; 0 = unlimited
world-load: 0 # load per persistent world
drain:
timeout: 30 # seconds the running work may take
force-grace: 5 # seconds after a forced stop
on-shutdown: true # drain when the host stops
token: "" # token for POST /altruist/server/drain from outside loopback
Next: run many servers as one — Fleet & Scaling.