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() cancels BaseCtx).
  • A query/RLS error fires for the same operation more than the configured threshold (cohort moves to error state).

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:

MetricTypeNotes
husaria_subs_cohorts_totalcounterkind={live,stream} — total cohorts created since process start.
husaria_subs_cohorts_activegaugeCurrently running cohorts.
husaria_subs_subscribers_activegaugeCurrently connected WS clients.
husaria_subs_ticks_totalcounteroutcome={success,error} — every cohort tick increments.
husaria_subs_emits_totalcounterTimes a tick produced a next payload (i.e., the result hash changed).
husaria_subs_tick_duration_secondshistogram5ms–10s buckets. The cost of one cohort tick (compile + execute + hash).
husaria_subs_dropped_subscribers_totalcounterreason={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 LISTEN consumer. 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.