Operating Husaria

Read-only replicas

Read-only replicas #

HUSARIA_READ_ONLY=true runs an instance of Husaria against a Postgres read replica. Queries and subscriptions resolve normally; mutations and console writes return 503 so a load balancer can route them to the primary.

This is the standard pattern for scaling reads on a high-traffic deployment: one writer instance pointed at the primary, N reader instances pointed at standbys, traffic shaped by the read_only flag on the health endpoint.

What changes when HUSARIA_READ_ONLY=true #

GraphQL endpoint #

OperationBehaviour
QueryPasses through normally. RLS and the response cache work as usual.
SubscriptionPasses through. Live queries are push-style reads against the standby.
MutationRejected with HTTP 200 + GraphQL error: {errors:[{message:"mutations are disabled in read-only mode", extensions:{code:"read-only"}}]}. Returning 200 keeps wire-shape clients happy; the error is in the standard errors array.

Console #

PathBehaviour
GET /console/api/*Passes through.
POST/PUT/DELETE/PATCH /console/api/*HTTP 503 + {error, read_only:true}.
POST /console/api/graphqlOperation-type gate — admin queries resolve, only mutations get the 503.

/api/actions/* and /v1/metadata #

EndpointBehaviour
/api/actions/* writesRejected (writes imply DB state change).
POST /v1/metadata export_metadataPass-through (read).
POST /v1/metadata reload_metadataPass-through (in-memory only, no DB writes).
POST /v1/metadata replace_metadataRejected (writes).

Bootstrap #

The husaria_data schema bootstrap is skipped on a read-only replica. The standby's catalog is replicated from the primary's WAL — the bootstrap CREATE SCHEMA / CREATE TABLE would fail with SQLSTATE 25006 (read-only transaction) and flood logs without changing anything. Only the primary attempts schema creation; replicas trust the replication stream.

Runtime settings #

runtimecfg.NewStore tolerates SQLSTATE 25006 on replicas. The husaria_data.runtime_settings rows are replicated from the primary, so even when the bootstrap CREATE TABLE would fail, the subsequent SELECT succeeds and the standby's in-memory cache reflects whatever overrides the primary wrote. The store also defends nil receivers — if it fails to initialise entirely, callers read it as "no override" and fall back to env defaults rather than panicking.

Health & version surface #

Both /healthz (and the readiness aliases) and /version surface read_only: true|false in their payload:

{
  "status": "ok",
  "read_only": true,
  "version": "..."
}

A typical load balancer pattern is to route based on this flag — primary handles writes, replicas handle reads, the LB shifts traffic during a planned failover.

Deployment recipe #

Step 1. Provision a Postgres read replica #

Use whatever your platform provides — CloudNativePG, postgres-operator, AWS RDS read replica, Crunchy Bridge, plain hot-standby. The replica needs the husaria_data schema present already (replicated from the primary at first install).

Step 2. Deploy a second Husaria instance pointed at the standby #

Identical configuration to the primary except:

  • HUSARIA_READ_ONLY=true
  • HUSARIA_DB_HOST (or DATABASE_URL) pointed at the standby
  • A different ingress hostname / service so it's reachable independently

Step 3. Wire your load balancer #

Route mutations and console writes to the primary; route everything else to whichever instance is closer (or has spare capacity). Health-check both instances on /ready and watch the read_only flag.

Step 4. Plan for failover #

Two failover paths to think about:

  • Planned failover — promote a replica to primary in Postgres; restart the corresponding Husaria with HUSARIA_READ_ONLY unset (or =false). The bootstrap step runs idempotently.
  • Unplanned failover — your LB needs to recognise that the previous primary is down. The read_only flag in /ready is your routing signal.

Open mode + read-only replica #

These flags are independent. A read-only replica can run in either OPEN MODE (no HUSARIA_ADMIN_SECRET) or SECURED MODE. Open-mode replicas inherit the "every caller is admin" semantics for reads only — mutations are blocked by the read-only middleware regardless.

That said: never expose an open-mode instance to untrusted networks, replica or primary. The read-only middleware blocks state-changing operations against the DB, but anyone hitting the open replica can still read every row in every table.

Configuration #

export HUSARIA_READ_ONLY=true
export HUSARIA_DB_HOST=postgres-replica.databases.svc.cluster.local
export HUSARIA_ADMIN_SECRET=admin-secret-key-change-me
export ENVIRONMENT=production

Everything else carries over from the primary's configuration. See Configuration for the full env-var surface.