trstctl /docs Demo ↗ GitHub ↗

Operations & resilience

The serving control plane is built so one overloaded or failing part cannot take down the rest: each subsystem runs in its own bounded lane and rejects fast when full. This page covers the resilience controls in the live path: bulkheads, the per-tenant rate limiter, graceful drain, and the fail-closed signer timeout.

Bulkheads (isolation + backpressure)

Each subsystem runs on its own bounded worker pool with a bounded queue: the API, projection workers, outbox dispatcher, signing path, heavy query path, policy engine, served issuance protocols, and the agent steady-state gRPC channel. When a pool is saturated it rejects fast rather than blocking — an API flood returns 503 with a Retry-After header, and an agent heartbeat/renewal flood returns gRPC ResourceExhausted with retry guidance, instead of consuming capacity another subsystem needs.

Because the pools are isolated, a saturated API cannot starve the things you rely on to observe and recover: /healthz, /readyz, and /metrics are served outside the API bulkhead and keep answering even while the API sheds load. The continuous outbox dispatcher runs on its own pool, so a backlog of external calls applies backpressure to itself (it sheds a sweep rather than piling up) without touching API capacity. The agent pool similarly isolates reconnect storms and certificate-renewal waves from the API/protocol/outbox pools, and the agent gRPC listener also caps streams per connection.

The pool sizes ship with conservative defaults and are tuned per deployment.

Outbox delivery fairness

The outbox worker does not keep a PostgreSQL transaction open while it calls an external CA, connector, webhook, or notification target. It first leases one due row in a short transaction (processing, worker_id, lease_until), commits, does the external call, then records success or retry state in a second short transaction. If a worker dies after claiming a row, the lease expires and another worker returns the row to pending.

Dispatch is also fair by tenant and destination. Each sweep rotates across tenants and destinations, with explicit in-flight caps per tenant and per destination, so one down connector or one noisy tenant cannot occupy every outbox worker while unrelated tenants wait behind it.

Each delivery also has a per-message deadline. If a connector, plugin, webhook, or notification target does not return before that deadline, the row is marked pending again through the normal retry/backoff path, and the served binary increments trstctl_outbox_delivery_timeouts_total{tenant_id,destination}. That counter is the operator's direct signal that one destination is timing out without starving the rest of the outbox.

Rate limiting (per tenant, PostgreSQL-backed)

A per-tenant token bucket, persisted in PostgreSQL (no Redis — the limit holds across every replica), sheds load on the guarded routes: each tenant may make requests calls per window, admitting a burst of requests and refilling steadily. Over-budget requests get 429 Too Many Requests with a Retry-After header. The check runs after authentication and authorization, so one noisy tenant cannot exhaust the control plane while others are unaffected.

Variable Default Meaning
TRSTCTL_RATE_LIMIT_ENABLED true Turn per-tenant rate limiting on/off.
TRSTCTL_RATE_LIMIT_REQUESTS 600 Burst/budget per window, per tenant.
TRSTCTL_RATE_LIMIT_WINDOW 1m The refill window (Go duration).

Graceful drain on shutdown

On SIGTERM the control plane drains without losing in-flight work: it stops accepting new connections, stops the outbox dispatcher, drains the per-subsystem worker pools (finishing queued and running tasks), runs a final outbox sweep so no enqueued external effect is lost (the journaled outbox guarantees at-least-once delivery), then closes the event log and datastore in order.

Fail-closed signing

Issuance is bounded by a per-operation timeout. If the out-of-process signer is slow, unreachable, or stopped, IssueLeaf fails closed — it returns an error within the timeout and never falls back to an in-process signature. This is exercised by fault injection (a deliberately slow signer) in the test suite.

What an operator should watch

Pair this with Observability: the trstctl_http_requests_total counter shows 429/503 shedding as it happens, and the alert rules fire on sustained error rate or latency. A rising 503 rate points at a saturated subsystem; a rising 429 rate points at a tenant over budget. A rising trstctl_outbox_delivery_timeouts_total series points at a slow destination and includes the affected tenant and destination labels.

For SIEM pipelines, enable OTLP export to your OpenTelemetry Collector. Served HTTP spans arrive as OTLP traces, and event-sourced audit records arrive as OTLP logs with trstctl.audit.sequence and trstctl.tenant.id attributes. Use the sequence attribute to dedupe restarted streams and to alert on gaps.

Rendered live from github.com/ctlplne/trstctl — found a mistake? edit this page.