Core
Subscriptions
Subscriptions #
Husaria implements two subscription flavours over a single graphql-transport-ws transport.
Live queries #
Defined in internal/graphql/subs/runtime.go. Each subscription polls on the interval set by HUSARIA_LIVE_QUERIES_REFETCH_INTERVAL_MS (default 1000ms), hashes the canonical-JSON result, and emits a next message only when the hash changes.
Identical operations are cohort-multiplexed — N subscribers running the same query share a single execution per tick. Cohorts are keyed by (operation hash, role, session-variable fingerprint, allowed columns) so two subscribers with different RLS contexts are not collapsed together; one cohort means "same role + same data".
Streaming subscriptions #
When the root field name ends in _stream, the runtime emits only rows past a server-tracked cursor instead of full snapshots. Suited for append-only feeds (logs, messages, events) where rebroadcasting the whole result is wasteful.
Cohort lifetime #
Every cohort goroutine descends from Runtime.BaseCtx — the server-lifetime context wired at engine boot. When the engine shuts down, BaseCtx is cancelled and every cohort tick exits cleanly without leaking a goroutine into a never-finishing poll loop.
The cohort exits when:
- The last subscriber leaves.
- The runtime is stopped (
Runtime.Stop()cancelsBaseCtx). - A query/RLS error fires for the same operation more than the configured threshold (cohort moves to
errorstate).
Operators on slow networks can be dropped from a cohort independently — see § Dropped subscribers below.
Transport #
The subscription endpoint is /v1/graphql/ws (override via HUSARIA_WEBSOCKET_PATH). The handshake is graphql-transport-ws. In secured mode the admin-secret check runs inside connection_init — subscriptions do not flow through the HTTP auth chain. Pass the admin secret in connectionParams.headers["X-Husaria-Admin-Secret"].
Role downgrade through X-Husaria-Role #
An admin-secret-authenticated WS can deliberately downgrade itself by sending X-Husaria-Role: <non-admin-role> in the same connection_init.payload.headers. The cohort then runs with that role and bypass=false — useful for previewing what a regular user sees from inside the console. The reverse is not true: a JWT-only WS cannot escalate to admin by setting X-Husaria-Role: admin.
Filters and operators #
Subscriptions share the same where / order_by / limit / offset / distinct_on surface as the corresponding Query field — including:
- Boolean operators (
_eq/_in/_and/_or/_gte/_like/ etc.) - Relationship traversal in
where(object + array rels) distinct_on
Whatever filters work on the Query root work in a subscription. The compiled SQL goes through the same buildSelectSQL path, so a subscription is exactly the cohort-multiplexed equivalent of polling that same query.
Metrics #
Prometheus series emitted by internal/graphql/subs/metrics.go:
| Metric | Type | Notes |
|---|---|---|
husaria_subs_cohorts_total | counter | kind={live,stream} — total cohorts created since process start. |
husaria_subs_cohorts_active | gauge | Currently running cohorts. |
husaria_subs_subscribers_active | gauge | Currently connected WS clients. |
husaria_subs_ticks_total | counter | outcome={success,error} — every cohort tick increments. |
husaria_subs_emits_total | counter | Times a tick produced a next payload (i.e., the result hash changed). |
husaria_subs_tick_duration_seconds | histogram | 5ms–10s buckets. The cost of one cohort tick (compile + execute + hash). |
husaria_subs_dropped_subscribers_total | counter | reason={slow,client_error} — see below. |
Label cardinality is bounded by design — no per-query, per-cohort, or per-subscriber labels.
Dropped subscribers #
Two reason values surface in husaria_subs_dropped_subscribers_total:
slow— the subscriber's send buffer overflowed because their network can't keep up with the cohort's emit rate. The runtime drops them rather than block the cohort tick for everyone else.client_error— the WebSocket closed with an error, or the client violated the protocol (sent garbage, dropped halfway through a frame).
The cohort itself continues serving the remaining subscribers.
Client wiring #
Pass the admin secret in the connectionParams map. Standard Apollo, urql, and graphql-ws clients work out of the box:
import { createClient } from 'graphql-ws'
const client = createClient({
url: 'ws://localhost:8080/v1/graphql/ws',
connectionParams: {
headers: { 'X-Husaria-Admin-Secret': 'admin-secret-key-change-me' }
}
})
For JWT-authenticated clients, send Authorization: Bearer <token> in the same headers map. The handshake runs the same auth path as /v1/graphql — see Authentication for the decision tree.
What's not in scope #
- Not server-pushed mutations. Subscriptions are poll-and-diff on the server side, not a Postgres
LISTENconsumer. The 1s default tick is the cost of catching every row change. Lower the interval at your own cost; raise it to reduce DB load. - Not federated. One Husaria instance per schema; multiple instances behind a load balancer all run independent cohorts against the same Postgres. There's no cross-instance cohort sharing.
- Not transactional with the source change. The poll-and-diff model means a row change is visible to a subscriber within one tick interval, not at commit time.