HeliosProxy Configuration Reference
HeliosProxy Configuration Reference
Complete reference for HeliosProxy (heliosdb-proxy) configuration. HeliosProxy uses
TOML for its configuration file. The authoritative parser is ProxyConfig (and its
section structs) in src/config.rs; every key documented here is a real field of that
type. Keys, sections, and defaults are kept in sync with the code — if a section is not
listed here, it is not part of ProxyConfig and is silently ignored on load (see
Unknown Keys).
Usage
# Start with a configuration fileheliosdb-proxy --config /etc/heliosproxy/config.toml
# Start with command-line arguments (no config file)heliosdb-proxy \ --listen 0.0.0.0:6432 \ --admin 127.0.0.1:9090 \ --primary db-primary:5432 \ --standby db-standby-1:5432 \ --standby db-standby-2:5432
# Override log levelheliosdb-proxy --config config.toml --log-level debug
# Emit JSON-structured logsheliosdb-proxy --config config.toml --json-logsCommand-Line Arguments
| Argument | Default | Description |
|---|---|---|
--config, -c | (none) | Path to TOML configuration file. |
--listen, -l | 0.0.0.0:5432 | Client (PostgreSQL-wire) listen address. |
--admin | 127.0.0.1:9090 | Admin API listen address. Loopback by default — see Admin API Security. |
--primary | (none) | Primary node host:port. |
--standby | (none) | Standby node host:port (repeatable). |
--tr | true | Enable Transaction Replay. Takes an optional value: --tr false or --tr=false turns it off; a bare --tr means true. |
--log-level | info | Log level: trace, debug, info, warn, error. |
--json-logs | false | Emit logs in JSON format. |
There is also an install skills subcommand (--target claude|codex|both,
--symlink, --force, --dry-run) for installing the bundled agent skills.
When --config is provided, the file drives the whole configuration. Command-line node
arguments (--primary, --standby) build an in-memory config only when no config file
is supplied.
Signals
- SIGHUP — live configuration reload.
- SIGUSR2 — graceful drain for a zero-downtime binary handoff, bounded by
shutdown_drain_timeout_secs(env overrideHELIOS_DRAIN_TIMEOUT_SECS). - There is no SIGTERM / Ctrl-C handler: SIGTERM terminates the process immediately.
Environment-Variable Substitution
Before the file is parsed as TOML, HeliosProxy expands environment-variable references
in the raw text (ProxyConfig::from_file → substitute_env):
| Syntax | Meaning |
|---|---|
${NAME} | Replaced with the value of environment variable NAME. If NAME is unset, startup fails fast with an error naming the variable — the literal is never left in the output. |
${NAME:-default} | Replaced with NAME if set, otherwise the literal default text (which may be empty; it runs up to the first }). |
listen_address = "${HELIOS_LISTEN:-0.0.0.0:5432}"admin_token = "${ADMIN_TOKEN}" # fails at startup if ADMIN_TOKEN is unset
[branch]admin_password = "${PGPASSWORD}"
# max_connections = ${POOL_MAX:-100} # unquoted numeric substitution is valid TOMLRules and limits:
NAMEmust match[A-Za-z_][A-Za-z0-9_]*.- Substitution is in place, so an unquoted
${POOL_MAX:-100}becomes the bare token100(valid TOML) and a quoted"${X:-y}"becomes"y". - Env lookup only. No shell is spawned and nothing is evaluated — only the env
lookup plus the
:-default operator run. - Comment handling is line-level. A line whose first non-whitespace byte is
#(a full-line comment) is copied verbatim and never substituted, so commented${VAR}examples in the shipped reference configs do not trigger the unset-variable error. A trailing#comment on a value line is not treated as a comment. - Trust boundary. Substitution is textual and runs before the TOML parse, so a
substituted value containing a quote or newline can inject arbitrary TOML structure.
Environment variables and the config file are operator-controlled, which makes this
acceptable — but do not feed an untrusted-party-controlled environment variable
into a
${...}reference.
Real environment variables
There is no generic HELIOS_PROXY_* “override any value” system. Only one runtime
environment variable is consulted directly by the proxy:
| Variable | Effect |
|---|---|
HELIOS_DRAIN_TIMEOUT_SECS | Overrides shutdown_drain_timeout_secs at runtime (SIGUSR2 drain bound). |
Everything else is configured through the file (optionally via ${VAR} substitution
above) or the command-line arguments.
Unknown Keys Are Warned, Not Rejected (By Default)
ProxyConfig does not use deny_unknown_fields. Any TOML key that is not a
recognized ProxyConfig field — top-level or nested inside a known section — is
parsed-and-ignored, and each one is logged once at startup as a warning, with its full
dotted path:
WARN unknown config key '<key>' ignored (not part of ProxyConfig)WARN unknown config key 'cache.l1_max_byts' ignored (not part of ProxyConfig)This makes silent doc-drift and typos visible without breaking pre-existing configs.
Historical example blocks such as [ha], [logging], [metrics], [routing],
[distribcache], [graphql], [[tenants]], and [[schema_routes]] are not part of
ProxyConfig — they will be warned and ignored. Their real counterparts are documented
below (lag_routing, multi_tenancy, graphql_gateway, schema_routing, …).
Detection covers every nesting depth (built on serde_ignored, not a hand-maintained
whitelist), so a typo inside an otherwise-valid section — e.g. [cache] l1_max_byts = 1
instead of max_result_bytes, or [journal] fsyncc = "commit" instead of fsync — is
caught exactly like an unknown top-level section. Set strict_config = true to turn
every one of these warnings into a hard startup failure instead (see below).
Top-Level Options
listen_address = "0.0.0.0:5432"admin_address = "127.0.0.1:9090"# admin_token = "..." # bearer token for the admin API (see below)# admin_allow_insecure = false# strict_config = false # reject settings this build cannot deliver (since 1.9.0)tr_enabled = truetr_mode = "session"tr_read_functions = []write_timeout_secs = 30optimize_unnamed_parse = trueshutdown_drain_timeout_secs = 60| Key | Type | Default | Description |
|---|---|---|---|
listen_address | string | "0.0.0.0:5432" | Address/port for PostgreSQL client connections. (Required in a config file.) |
admin_address | string | "127.0.0.1:9090" | Address/port for the admin HTTP API. Loopback by default. (Required in a config file.) |
admin_token | string | (none) | Bearer token required on every admin endpoint except liveness probes. See Admin API Security. |
admin_allow_insecure | bool | false | Explicit opt-in to expose the admin API on a non-loopback address without a token. |
strict_config | bool | false | Opt-in strict configuration (since 1.9.0): startup fails when an enabled subsystem was not compiled into the binary (e.g. [cache] enabled = true without the query-cache feature) or when an unknown config key is present, at any nesting depth (an unknown top-level section, or a typo’d key inside a known section such as [cache] l1_max_byts). Default false keeps the historical warn-and-continue behaviour. GET /capabilities reports the per-subsystem compiled/enabled/wired state. |
tr_enabled | bool | true | Master switch for Transaction Replay, which ships in the default build. When false: the write journal stops recording, tr_mode is forced to none, and POST /api/replay returns 503. (Required in a config file.) |
tr_mode | string | "session" | Transaction Replay mode: none, session, select, transaction. (Required in a config file.) |
tr_read_functions | array of string | [] | Read re-execution policy extension (since 1.7.0). In-session replay re-executes an interrupted read on an unknown outcome only when every function it calls is a PostgreSQL built-in known to be side-effect-free; list additional provably pure functions (unqualified names, case-insensitive) here. Reads calling anything else, quoted-identifier calls, SELECT … INTO, and sequence functions are classified as opaque and never re-executed. |
write_timeout_secs | u64 | 30 | Seconds to buffer writes during failover before returning an error — and, since 1.7.0, the single deadline for a whole session recovery: waiting for a primary, connect/auth, session-state restore and replay all draw on it, instead of each having its own timeout. |
optimize_unnamed_parse | bool | true | Skip re-forwarding an identical unnamed extended-protocol Parse a backend already holds, synthesizing ParseComplete locally. A kill-switch for drivers that depend on the redundant round trip. |
shutdown_drain_timeout_secs | u64 | 60 | How long a SIGUSR2 binary-handoff drain keeps serving in-flight connections before dropping them. Runtime override: HELIOS_DRAIN_TIMEOUT_SECS. |
The sections [pool], [load_balancer], [health], and at least one [[nodes]] entry
are also required in a config file (they have no serde defaults). Every other section
listed below is optional and defaults to disabled/off.
Transaction Replay Modes (tr_mode)
tr_mode governs in-session failover: what a live client session experiences
when its backend connection fails while a request is in flight (write error /
timeout, read error, EOF, reset) or dies while idle. It needs no cargo feature —
Transaction Replay is in the default build — but tr_enabled = false forces the
effective mode to none and disables the operator-driven POST /api/replay
engine at the same time. Two fault phases are distinguished:
not-delivered (the request never reached the backend — it certainly did not
run) and outcome-unknown (written, then the connection failed before
ReadyForQuery).
| Mode | Behaviour on a backend fault |
|---|---|
none | One ErrorResponse (SQLSTATE 57P01, naming the failed node) + ReadyForQuery, then the client connection is closed. |
session (default) | The client connection stays open. The proxy waits for a healthy primary (write_timeout_secs), reconnects (startup parameters re-sent), replays the session’s tracked SET/RESET statements, and re-prepares named prepared statements lazily. A not-delivered statement issued outside an explicit transaction is re-executed transparently. Anything else gets ONE error — 57P01 (not delivered inside a transaction) or 08007 transaction_resolution_unknown — the client-visible transaction is aborted (ROLLBACK and retry; other statements get 25P02 until then) and the session continues on the new primary. |
select | session, plus: an outcome-unknown read (SELECT/SHOW/VALUES/read-only WITH/COPY … TO) is re-executed transparently outside a transaction, but only when every function it calls is known side-effect-free — a PostgreSQL built-in on the allowlist or a name you listed in tr_read_functions. A read calling a user-defined function, nextval, pg_notify, set_config, an advisory lock, a quoted-identifier call, or using SELECT … INTO is opaque: it returns 08007 and is never run twice. A read-only explicit transaction is replayed from its BEGIN first and the interrupted statement re-run inside it. |
transaction | select, plus: an uncommitted explicit transaction is replayed from its BEGIN on the new primary (recorded simple-protocol text / raw extended-protocol batches) and the in-flight statement is re-executed inside it. Replay is verified: each statement’s original response frames were digested as the client saw them (within [limits] tr_max_observation_bytes) and the replacement backend’s responses are digested the same way — any divergence rolls the replay back and returns 40001, so a replay that would silently continue on different rows fails loudly instead. A failed replayed statement produces the same 40001. A COMMIT/END/PREPARE TRANSACTION/COMMIT PREPARED whose outcome is unknown is never retried (08007). Still opt-in: verification catches divergent results, it does not make now() or a RETURNING serial reproduce its first value. |
Hard rules in every mode: a write whose outcome is unknown is never re-executed
except as part of transaction mode’s replay of an uncommitted transaction (the
original died uncommitted with the old backend); a COMMIT with unknown outcome
is never retried; SAVEPOINT/RELEASE/ROLLBACK TO are ordinary replayed
statements; a COPY in progress at the fault → 08006 and the connection is
closed. A transaction is marked non-replayable (so transaction degrades to
session for it) when it exceeds [limits] tr_max_replay_statements /
tr_max_replay_bytes, enters the failed state, contains a COPY, or one of
its statements was transformed by query-rewrite / multi-tenancy, runs at
SERIALIZABLE or REPEATABLE READ (explicitly, via SET TRANSACTION, or
inherited from a tracked default_transaction_isolation — no replay can
reproduce that snapshot), or produced a response too large to have been digested
within [limits] tr_max_observation_bytes.
Session SET tracking is transactional: a SET inside a transaction takes effect
in the restore set only when that transaction commits, ROLLBACK TO SAVEPOINT
discards the ones made after the savepoint, and RESET/RESET ALL/DISCARD ALL
inside a transaction are deferred to commit. Variables are keyed by name, so
repeating a SET reuses its slot; the cap tr_max_session_set_statements counts
distinct variables, and exceeding it refuses the failover with 08006 rather
than re-homing a session with incomplete state. It covers simple-protocol
statements only — extended-protocol SETs as sent by JDBC, SQL-level PREPARE,
session temp tables and cursors are not restored. (Extended-protocol named
prepared statements are a separate mechanism: those are re-prepared lazily on the
replacement backend.)
Backend credentials. Re-homing a session means opening a fresh backend
connection. In pass-through auth mode the proxy never sees the client’s
password (SCRAM is designed for that), so a fresh connection can only be
completed against a backend that does not challenge (trust); a challenging
backend fails the recovery immediately with 08006 (“the proxy could not
authenticate to the replacement primary”). To fail over onto password-protected
(SCRAM-SHA-256 / MD5 / cleartext) backends, make the proxy the auth boundary:
[auth] mode = "scram" with a plaintext auth_file entry for the user —
the proxy then authenticates the client itself and uses the same secret as a
SCRAM client towards every backend connection it opens (this also removes the
former “scram mode needs a trust backend” restriction on the first connection).
Counters on /metrics and /metrics/prometheus: tr_failovers_total,
tr_statements_reexecuted_total, tr_transactions_replayed_total,
tr_replay_failures_total, tr_unknown_outcome_errors_total,
tr_replay_cap_exceeded_total, tr_session_set_cap_exceeded_total.
Admin API Security
The admin API runs privileged operations (arbitrary SQL via /api/sql, forced failover
via /api/chaos, migration cutover, branch CREATE/DROP DATABASE, replay/shadow
against operator-chosen targets), so its exposure is guarded at startup:
- Default bind is loopback (
127.0.0.1:9090). A fresh install is safe. - If
admin_addressparses to a non-loopback IP andadmin_tokenis unset andadmin_allow_insecureisfalse, the proxy refuses to start with a descriptive error. Fix it by settingadmin_token, binding to127.0.0.1, or settingadmin_allow_insecure = true(only when you front the admin port with your own authenticating proxy / network policy). - When
admin_tokenis set, every admin endpoint requiresAuthorization: Bearer <token>except the liveness probes.
Startup validation also rejects health.check_interval_secs = 0 (a zero interval would
panic the health-check timer and silently stop probing).
Pool Mode ([pool_mode])
Controls Session/Transaction/Statement pooling.
[pool_mode]mode = "transaction"max_pool_size = 100min_idle = 10idle_timeout_secs = 600max_lifetime_secs = 3600acquire_timeout_secs = 5reset_query = "DISCARD ALL"prepared_statement_mode = "track"skip_clean_reset = false| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "session" | Pooling mode: session, transaction, statement. |
max_pool_size | u32 | 100 | Maximum backend connections per node. |
min_idle | u32 | 10 | Minimum idle connections to maintain. |
idle_timeout_secs | u64 | 600 | Close idle connections after this many seconds. |
max_lifetime_secs | u64 | 3600 | Recycle connections after this many seconds. |
acquire_timeout_secs | u64 | 5 | Max seconds to wait when acquiring from the pool. Also bounds how long a new backend connection keeps retrying when PostgreSQL refuses it at max_connections (SQLSTATE 53300): each retry first closes one idle pooled connection to that node, and only after this timeout does the client receive the 53300. Size the backend’s max_connections for the pool plus the expected burst of new client logins, since every pass-through login opens its own backend connection. |
reset_query | string | "DISCARD ALL" | SQL run when a connection returns to the pool. |
prepared_statement_mode | string | "disable" | Prepared-statement handling: disable, track, named. |
skip_clean_reset | bool | false | Transaction/Statement pooling only: park a connection that provably touched no session state (no SET/GUC, temp table, prepared statement, LISTEN, advisory lock, …) without running reset_query, saving a round-trip per clean transaction. Classification is conservative — a misclassification only ever costs an unnecessary reset, never leaks state. Intended for autocommit / simple-protocol workloads. |
Pooling Modes
| Mode | Returns To Pool | Best For |
|---|---|---|
session | When the client disconnects (1:1 client↔backend). | Prepared statements, long-running sessions, legacy apps. |
transaction | After COMMIT/ROLLBACK. | Web apps, microservices, connection-starved environments. |
statement | After each statement. | Simple read-heavy workloads without multi-statement transactions. |
Prepared Statement Modes
| Mode | Behavior |
|---|---|
disable | Not tracked. Safest for transaction/statement pooling. |
track | Track PREPARE/DEALLOCATE and recreate on a new backend connection. |
named | Protocol-level named statements. Compatible with session pooling. |
Connection Pool ([pool])
Core per-node connection pool. (Required section.)
[pool]min_connections = 2max_connections = 100idle_timeout_secs = 300max_lifetime_secs = 1800acquire_timeout_secs = 30test_on_acquire = true| Key | Type | Default | Description |
|---|---|---|---|
min_connections | usize | 2 | Minimum connections per node. |
max_connections | usize | 100 | Maximum connections per node. Must be ≥ min_connections. |
idle_timeout_secs | u64 | 300 | Close connections idle longer than this. |
max_lifetime_secs | u64 | 1800 | Maximum connection lifetime before recycling. |
acquire_timeout_secs | u64 | 30 | Max wait for a connection from the pool. |
test_on_acquire | bool | true | Health-check a connection before handing it out. |
Load Balancer ([load_balancer])
(Required section.)
[load_balancer]read_strategy = "round_robin"read_write_split = truelatency_threshold_ms = 100| Key | Type | Default | Description |
|---|---|---|---|
read_strategy | string | "round_robin" | Read routing strategy (see below). |
read_write_split | bool | true | Route writes to the primary, reads to standby/replica nodes. |
latency_threshold_ms | u64 | 100 | Latency above which a node is treated as unhealthy for routing. |
Routing Strategies
| Strategy | Description |
|---|---|
round_robin | Rotate through nodes equally. |
weighted_round_robin | Rotate proportionally to each node’s weight. |
least_connections | Route to the node with the fewest live sessions currently attached to it. |
latency_based | Route to the node with the lowest measured health-probe latency. |
random | Pick a node at random. |
power_of_two | Sample two eligible nodes and pick the one with fewer attached sessions, breaking ties on measured latency (since 1.9.0). Cheaper and more herd-resistant than global least-connections. |
All strategies are applied to the healthy, enabled standbys after circuit-breaker and
lag exclusion; a strategy is only ever asked to choose among eligible nodes. The daemon
honors [load_balancer] read_strategy on the read path (before 1.9.0 the daemon hardcoded
round-robin).
Health Checks ([health])
(Required section.)
[health]check_interval_secs = 5check_timeout_secs = 3failure_threshold = 3success_threshold = 2check_query = "SELECT 1"| Key | Type | Default | Description |
|---|---|---|---|
check_interval_secs | u64 | 5 | Interval between probes. Must be ≥ 1 (0 is rejected at startup). |
check_timeout_secs | u64 | 3 | Max wait for a probe response. |
failure_threshold | u32 | 3 | Consecutive failures before marking a node unhealthy. |
success_threshold | u32 | 2 | Consecutive successes before a node marked unhealthy is considered healthy again. |
check_query | string | "SELECT 1" | Health-check query. Used by the daemon only when [health] user is set; the default credential-less probe is a PostgreSQL SSLRequest liveness check. |
user / password / database | string / string / string | (none) | Optional credentials for the daemon’s health and lag probes (since 1.9.0). When set, the daemon connects and runs check_query (and, for standbys, WAL-position lag probes) instead of the credential-less protocol probe. |
Topology ([topology])
Authoritative primary tracking (since 1.9.0; patroni since 2.0.0). Optional; absent = provider = "static",
which preserves the historical behaviour (the configured role = "primary" node whose
health check passes is the primary).
[topology]provider = "static" # static | postgres | patronipoll_interval_secs = 2lease_timeout_secs = 10user = "postgres"# password = "${HELIOS_PROXY_TOPOLOGY_PASSWORD}"database = "postgres"# patroni_endpoints = ["http://10.0.0.1:8008", "http://10.0.0.2:8008"]# patroni_request_timeout_ms = 2000| Key | Type | Default | Description |
|---|---|---|---|
provider | string | "static" | static: configured roles + the daemon health checker decide the primary. postgres: poll pg_is_in_recovery() on every configured node and treat the non-recovering node as the primary. patroni: poll the Patroni REST API (GET /cluster) and treat the cluster’s running leader as the primary. postgres and patroni require the postgres-topology cargo feature — a config that requests them on a build without that feature is rejected at startup. |
poll_interval_secs | u64 | 2 | Seconds between provider polls. Must be ≥ 1 (0 is rejected at startup). |
lease_timeout_secs | u64 | 10 | How long a provider observation stays authoritative without a refresh (since 1.9.0). When the provider cannot be reached, the write path stops after at most this long instead of proceeding on stale knowledge. Must be ≥ 1. |
user | string | "postgres" | User for the provider’s own probe connections. |
password | string | (none) | Password for the probe user, if required. |
database | string | "postgres" | Database the probe connects to. |
patroni_endpoints | list of URLs | [] | Patroni REST API base URLs (http(s)://host:8008), tried in order each poll; the first that answers is used. Required for provider = "patroni"; each must start with http:// or https://. |
patroni_request_timeout_ms | u64 | 2000 | Timeout for one Patroni REST request. Must be ≥ 1. |
When a provider is configured it is authoritative for the write path: writes go only
to the provider’s leader (which must be an enabled [[nodes]] entry), the answer waits
rather than falling back to a configured role while no leader is known, and
GET /topology reports the leader plus an authoritative block with its authority
epoch (incremented on every observed leader change), valid, and the remaining lease.
This is how a real promotion moves the write destination without hand-editing
proxy.toml roles.
Fencing limits. A proxy-side epoch/lease cannot fence a client that connects
directly to a database node: the proxy can only refuse to route, and it fails closed once
the lease expires (valid: false, writes wait). Preventing two sides of a partition from
both accepting writes requires enforcement by the database (synchronous replication and
promotion fencing) or by exclusive network/backend control. The proxy never advertises
zero-loss (RPO 0) for an asynchronous replica that has not acknowledged the last commit —
configure synchronous replication at the database if that is your requirement.
Nodes ([[nodes]])
One entry per backend. At least one node with role = "primary" is required.
[[nodes]]host = "db-primary.internal"port = 5432http_port = 8080role = "primary"weight = 100enabled = truename = "primary-1"
[[nodes]]host = "db-standby-1.internal"port = 5432role = "standby"weight = 100enabled = truename = "standby-1"| Key | Type | Default | Required | Description |
|---|---|---|---|---|
host | string | — | Yes | Backend hostname or IP. |
port | u16 | — | Yes | PostgreSQL-protocol port. |
http_port | u16 | 8080 | No | HTTP API port on the backend node (SQL API forwarding). |
role | string | — | Yes | primary, standby, or replica. |
weight | u32 | — | Yes* | Load-balancing weight. |
enabled | bool | — | Yes* | Whether the node is routable. Toggleable at runtime via the admin API. |
name | string | (none) | No | Human-readable name for logs/metrics/admin. |
* weight and enabled have no serde default — supply them explicitly per node.
Node Roles
| Role | Description |
|---|---|
primary | Read/write node. All writes and transaction-control statements route here. At least one required. |
standby | Promotable standby. Eligible for failover; receives reads when read_write_split is on. |
replica | Read-only replica. Not promotable; receives reads only. |
TLS ([tls])
Optional TLS termination for client connections. Omit the whole section to disable.
[tls]enabled = truecert_path = "/etc/heliosproxy/server.crt"key_path = "/etc/heliosproxy/server.key"ca_path = "/etc/heliosproxy/ca.crt"require_client_cert = false| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | — | Enable TLS for client-facing connections. |
cert_path | string | — | PEM server certificate path. |
key_path | string | — | PEM private key path. |
ca_path | string | (none) | CA cert for client-certificate verification. |
require_client_cert | bool | — | Require a valid client certificate. |
Query Cache ([cache])
In-process query-result cache. Only active with the query-cache feature and
enabled = true. It is off by default and stays opt-in in 2.0: invalidation is
commit-aware for everything that goes through the proxy (below), but cached results are
not keyed by session state, and writes that bypass the proxy are bounded only by
ttl_secs — see the two caveats at the end of this section before enabling it.
[cache]enabled = truettl_secs = 300max_result_bytes = 1048576max_cacheable_response_bytes = 4194304| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Serve read SELECT results from the L1/L2 cache. |
ttl_secs | u64 | 300 | Time-to-live for cached results. |
max_result_bytes | usize | 1048576 | Largest single result to cache; larger results bypass. |
max_cacheable_response_bytes | usize | 4194304 (4 MiB) | (1.6.0) Ceiling on the transient buffer used to capture a response while it streams. A response that grows past it is marked non-cacheable and the buffer is released; the client always receives the full, unmodified response either way. Must be >= 1 (a 0 is rejected at startup, since it would make every response uncacheable while still looking like a working cache). Sits above the 1 MiB max_result_bytes default so it never masks that knob, and it governs the shared capture used by both the query cache and the edge cache. |
Invalidation is commit-aware on both protocols (since 2.0.0). Every cached result records the write generation of the tables it read, taken before its backend fetch, and every hit — L1, L2 or L3 — is served only while no write to those tables has happened since. Writes are recognised from what the backend actually executed (the same capture that feeds the transaction journal), on the simple and the extended protocol alike:
- a successful autocommit write moves its tables’ generation before the
ReadyForQuerythat acknowledges it reaches the client, so no read that starts afterwards can hit an older entry; - inside an explicit transaction the tables are staged (other sessions cannot see the rows yet, and a session in a transaction is never served from the cache) and moved when the backend reports the commit, before the client sees it, so an entry a concurrent reader refilled from pre-commit data is refused; a rollback drops the stage;
- a result whose tables were written while it was being fetched is not stored;
- a write whose tables cannot be read from its text — DDL,
TRUNCATE,COPY ... FROM,EXECUTE,CALL,DO— invalidates the whole cache.
Not covered: writes that do not pass through this proxy (another proxy, a direct
connection, a trigger or function writing a table other than the one named in the
statement), and two-phase commit (PREPARE TRANSACTION / COMMIT PREPARED: the
prepared transaction’s tables are not invalidated when it is committed). Such changes
become visible when the entry’s ttl_secs expires; keep the TTL short, or leave the
cache off, for tables written that way.
Why it stays opt-in: cache identity is the SQL, the database, the user and the branch — not session state.
Two sessions of the same user that differ only in SET ROLE, search_path, or a custom
setting read by row-level-security policies (for example app.tenant_id set per request
on a shared connection user) can be served each other’s cached result. Leave the cache
off for workloads whose results depend on such session state. The proxy’s own
[multi_tenancy] filtering is safe: it rewrites the SQL per tenant before the cache
lookup.
Lag-Aware Routing ([lag_routing])
Replica-lag-aware routing + read-your-writes. Only enforced with the lag-routing
feature and enabled = true.
[lag_routing]enabled = trueryw_window_ms = 500max_lag_bytes = 0require_known_lag = false| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enable lag-aware read routing + read-your-writes. |
ryw_window_ms | u64 | 500 | Reads within this many ms of a write in the same session pin to the primary (read-your-writes). 0 disables the window. |
max_lag_bytes | u64 | 0 | Exclude a standby when its measured replication lag exceeds this many bytes. 0 = no lag-based exclusion. |
require_known_lag | bool | false | Strict freshness: when true, a standby whose lag was never measured is excluded instead of being treated as fresh. Lag is measured only when [health] user is configured and this feature is enabled. |
Routing Hints ([routing_hints])
SQL-comment routing hints (/*helios:route=primary*/). Only honored with the
routing-hints feature and enabled = true.
[routing_hints]enabled = truestrip_hints = true| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Parse and honor /*helios:...*/ hints; an applied hint overrides default verb routing (but never a plugin Block). |
strip_hints | bool | true | Remove the hint comment from SQL before forwarding to the backend. |
Rate Limiting ([rate_limit])
Token-bucket + concurrency limiting. Only enforced with the rate-limiting feature and
enabled = true.
[rate_limit]enabled = truedefault_qps = 1000default_burst = 2000max_concurrent = 0key_by = "user"| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enforce rate limits. |
default_qps | u32 | 1000 | Sustained queries/sec per bucket. |
default_burst | u32 | 2000 | Token-bucket depth (burst) per bucket. |
max_concurrent | u32 | 0 | Max concurrent in-flight queries per bucket (0 = engine default). |
key_by | string | "user" | Bucket key: user, client_ip, database, global. |
Circuit Breaker ([circuit_breaker])
Per-node circuit breaker. Only enforced with the circuit-breaker feature and
enabled = true.
[circuit_breaker]enabled = truefailure_threshold = 5open_secs = 10success_threshold = 3| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Trip failing backends out of rotation. |
failure_threshold | u32 | 5 | Consecutive failures that open a node’s circuit. |
open_secs | u64 | 10 | How long a circuit stays open before a half-open probe. |
success_threshold | u32 | 3 | Successful probes required to close a half-open circuit. |
Operational Limits ([limits])
Session/protocol safety bounds and relay timeouts. Each key was previously a
compiled-in constant; the defaults reproduce those constants exactly, so an
absent [limits] block is byte-for-byte unchanged. All values are resolved once
at startup. validate() rejects 0 for the safety bounds (where a 0 would
disable a bound rather than mean anything useful) and caps every *_secs key at
one year (31536000), which would otherwise overflow the connect-time deadline.
The two 1.6.0 opt-in bounds are the deliberate exceptions: max_client_connections
and client_idle_timeout_secs both use 0 to mean unlimited / disabled, which is
their default and reproduces the pre-1.6.0 behaviour.
[limits]max_cancel_keys = 100000startup_timeout_secs = 30backend_write_timeout_secs = 30backend_read_timeout_secs = 30client_write_timeout_secs = 60reprepare_timeout_secs = 15max_prepared_statements = 8192max_prepared_bytes = 67108864max_pending_bytes = 67108864max_backend_frame_bytes = 104857600backend_response_timeout_secs = 0tr_max_observation_bytes = 1048576max_total_idle_backend_conns = 8192pool_reap_interval_secs = 30replay_deadline_secs = 300max_client_connections = 0client_idle_timeout_secs = 0client_admission_wait_secs = 0gateway_pool_max_idle = 16tr_max_replay_statements = 1000tr_max_replay_bytes = 4194304tr_max_session_set_statements = 256| Key | Type | Default | Description |
|---|---|---|---|
max_cancel_keys | usize | 100000 | Capacity of the query-cancellation key map (BackendKeyData → backend address); at capacity the oldest entries are FIFO-evicted. |
startup_timeout_secs | u64 | 30 | Deadline for the pre-auth startup exchange (TLS negotiation + startup/authentication); bounds slow-loris handshakes. |
backend_write_timeout_secs | u64 | 30 | Timeout for a single backend write on the forward path. |
backend_read_timeout_secs | u64 | 30 | Timeout for a single backend read on the relay path (paired with backend_write_timeout_secs; a slow-but-healthy read is not itself a fault). |
client_write_timeout_secs | u64 | 60 | Timeout for a single client write, so a wedged client cannot pin a proxy task (and its backend connection) forever. |
reprepare_timeout_secs | u64 | 15 | Timeout for the out-of-band re-prepare exchange performed on a backend connection switch. |
max_prepared_statements | usize | 8192 | Per-session cap on distinct named prepared statements. |
max_prepared_bytes | usize | 67108864 | Per-session cap on aggregate bytes retained in the statement registry (64 MiB). |
max_pending_bytes | usize | 67108864 | Per-session cap on the un-flushed extended-protocol pending buffer (64 MiB). |
max_backend_frame_bytes | usize | 104857600 | Cap on the declared length of one backend response frame on every streaming relay; a header above it or below the 4-byte minimum closes the backend as malformed (100 MiB, the frontend message cap). Bounds a frame, not a result set. |
backend_response_timeout_secs | u64 | 0 | Whole-response deadline for one backend response on the streaming relays, first read to ReadyForQuery. backend_read_timeout_secs re-arms per read and cannot catch a slow drip inside one response; this can. 0 disables (prior behaviour). |
tr_max_observation_bytes | usize | 1048576 | In-session TR: response bytes hashed per recorded statement into an observation digest, re-verified when the transaction is replayed; divergence rolls the replay back with 40001. A response over the cap has no verifiable digest, so its transaction becomes non-replayable. |
max_total_idle_backend_conns | usize | 8192 | Global ceiling on idle backend-pool connections across all (node,user,db) identities. Only consumed with the pool-modes feature; parsed-and-ignored otherwise. |
pool_reap_interval_secs | u64 | 30 | How often the idle-connection reaper runs. |
replay_deadline_secs | u64 | 300 | Overall wall-clock deadline for one operator time-window replay (POST /api/replay). query_timeout bounds each statement; this bounds the run. On expiry the replay stops at the current statement and reports deadline_exceeded + partial progress. 0 disables the overall deadline. Max 31536000. |
max_client_connections | usize | 0 | Since 1.6.0. Ceiling on concurrently-served client connections on the PG-wire listener. 0 = unlimited. Excess connections are refused with SQLSTATE 53300 (see client_admission_wait_secs) and counted in /metrics as connections_rejected; cancel requests are never refused. Applied at startup; a SIGHUP change is ignored with a warning. |
client_idle_timeout_secs | u64 | 0 | Since 1.6.0. Close an authenticated session that sits this many seconds between statements, with SQLSTATE 57P05. 0 = wait forever (historical behaviour). A session idle inside an open transaction is terminated too (PostgreSQL’s separate idle_in_transaction_session_timeout is not implemented). Applied at startup. |
client_admission_wait_secs | u64 | 0 | Bounded fair-admission queue (since 1.9.0): when max_client_connections is reached, a new connection waits up to this many seconds for a permit before being refused with 53300. 0 refuses immediately. Health/admin traffic is unaffected (health probes hold no client slot; admin is a separate listener). Applied at startup. |
gateway_pool_max_idle | usize | 16 | Idle authenticated backend connections kept per identity by each non-PG-wire gateway (HTTP SQL, MCP, GraphQL) for reuse (since 1.9.0). 0 disables pooling (each gateway request dials, the pre-1.9.0 behaviour). Gateways have separate pools so a session policy (e.g. MCP read-only) cannot leak across interfaces. Applied at startup. |
tr_max_replay_statements | usize | 1000 | In-session TR (tr_mode = select|transaction): cap on statements recorded per explicit transaction for failover replay. Over the cap the transaction is marked non-replayable (transaction degrades to session for it; tr_replay_cap_exceeded_total). |
tr_max_replay_bytes | usize | 4194304 | In-session TR: cap on bytes (statement text / raw extended-protocol frames) recorded per explicit transaction (4 MiB). Same degradation as above. |
tr_max_session_set_statements | usize | 256 | In-session TR (tr_mode != none): cap on DISTINCT session variables whose latest SET is replayed onto the replacement backend (a later SET of the same variable replaces the earlier one; RESET name removes it; RESET ALL/DISCARD ALL clear all). Over the cap tracking stops (tr_session_set_cap_exceeded_total) and a subsequent failover is refused with 08006 rather than re-homing the session with incomplete state. |
Recovery Journal ([journal])
Retention and durability of the Transaction Replay recovery journal (since 1.9.0) — the
committed-transaction log behind POST /api/replay. Journaling itself follows the
top-level tr_enabled; this section only bounds what is kept and where. Every key
defaults to the value it had as a compiled-in constant, and an absent [journal]
block keeps the journal in process memory only. validate() rejects 0 for every
cap (a 0 would disable a safety bound), an unknown fsync value, and
fsync_interval_ms = 0 under fsync = "interval".
[journal]max_active_transactions = 50000max_entries_per_transaction = 10000max_bytes_per_transaction = 67108864max_committed_transactions = 50000max_committed_bytes = 268435456max_statement_bytes = 1048576dir = ""segment_bytes = 67108864retain_bytes = 1073741824fsync = "interval"fsync_interval_ms = 100writer_queue = 4096| Key | Type | Default | Description |
|---|---|---|---|
max_active_transactions | usize | 50000 | Cap on open (uncommitted) transaction journals across all sessions; the oldest are evicted at the cap. Only bites when many sessions hold long transactions. |
max_entries_per_transaction | usize | 10000 | Statements journaled per transaction. Beyond it the transaction is kept but marked incomplete (committed-history replay refuses it rather than apply it partially). |
max_bytes_per_transaction | usize | 67108864 | Bytes (statement text + parameters) journaled per transaction (64 MiB). Same incomplete marking. |
max_committed_transactions | usize | 50000 | Committed transactions retained in memory for replay, oldest evicted first. Also the number reloaded from dir at startup. |
max_committed_bytes | usize | 268435456 | Bytes of committed transactions retained in memory (256 MiB). |
max_statement_bytes | usize | 1048576 | A single statement longer than this (1 MiB) is journaled without its text and marks its transaction incomplete — bounds per-statement memory on the capture path. |
dir | string | "" | Directory of the durable segment store. Empty = memory only: nothing survives a restart. When set, every committed transaction is appended to journal-<first-commit-seq>.log segments there and the newest max_committed_transactions are reloaded at startup with commit numbering continuing after the highest recovered sequence. An unwritable directory fails startup. The segments hold statement text and bound parameter values of committed writes, unencrypted: protect the directory like the database’s own data files. Records are CRC-checked and postcard-encoded (record format HJ02); a directory written by a pre-release 1.9.0 build (format HJ01) is refused at startup with an explicit error rather than truncated. |
segment_bytes | u64 | 67108864 | Bytes after which the current segment is closed and a new one started (64 MiB). |
retain_bytes | u64 | 1073741824 | Total on-disk bytes to keep; the oldest whole segments beyond it are deleted (1 GiB). |
fsync | string | "interval" | When appended records reach stable storage: "commit" = fdatasync after every record, "interval" = group fdatasync every fsync_interval_ms, "none" = leave it to the OS page cache. The data path never waits on disk under any policy (see writer_queue). |
fsync_interval_ms | u64 | 100 | Group-fsync period for fsync = "interval". |
writer_queue | usize | 4096 | Bounded queue between the data path and the segment writer thread. A full queue never stalls a client: the record is dropped and counted (reported as coverage.dropped_transactions on replay responses). |
What the journal records is described in transaction-replay.md.
Query Analytics ([analytics])
Fingerprinting, per-query stats, slow-query log, pattern detection. Only active with the
query-analytics feature and enabled = true.
[analytics]enabled = trueslow_query_ms = 1000max_fingerprints = 10000| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Record per-query statistics and slow-query log. |
slow_query_ms | u64 | 1000 | Queries slower than this are added to the slow-query log. |
max_fingerprints | u32 | 10000 | Maximum distinct query fingerprints to track. |
Anomaly Detection ([anomaly])
Tunables for the in-process anomaly detector (SQL-injection patterns, failed-auth
bursts, per-tenant rate spikes, novel-query shapes). The section is parsed on
every build for config round-tripping, but the detector is only active with the
anomaly-detection feature. The defaults reproduce the prior hardcoded behavior
exactly, so an absent [anomaly] block changes nothing.
The detector is built once at startup, so changing [anomaly] requires a
restart — a SIGHUP config reload does not rebuild it.
[anomaly]rate_window_secs = 60spike_z_threshold = 3.0auth_window_secs = 60auth_critical_count = 10auth_warning_count = 5event_buffer_size = 1024emit_novel_queries = truemax_seen_fingerprints = 100000| Key | Type | Default | Description |
|---|---|---|---|
rate_window_secs | u64 | 60 | Rolling window for the per-tenant rate EWMA, seconds. Must be >= 1. |
spike_z_threshold | f64 | 3.0 | Minimum z-score before a rate spike fires. Must be finite and > 0. |
auth_window_secs | u64 | 60 | Window for failed-auth (credential-stuffing) bursts, seconds. Must be >= 1. |
auth_critical_count | u32 | 10 | Failures inside the auth window that escalate to Critical. Must be >= 1. |
auth_warning_count | u32 | 5 | Failures inside the auth window that escalate to Warning. Must be <= auth_critical_count. |
event_buffer_size | usize | 1024 | Maximum events kept in the in-memory ring buffer. Must be >= 1. |
emit_novel_queries | bool | true | Emit first-seen query fingerprints as informational events; set false on high-churn workloads. |
max_seen_fingerprints | usize | 100000 | Upper bound on the novel-query fingerprint set before it is cleared (bounds memory on high-cardinality SQL). Must be >= 1. |
Query Rewriting ([query_rewrite])
Rules-engine SQL rewriting. Only active with the query-rewriting feature and
enabled = true.
[query_rewrite]enabled = true
[[query_rewrite.rules]]match_table = "orders"append_where = "deleted_at IS NULL"
[[query_rewrite.rules]]match_regex = "^SELECT \\* FROM events"add_limit = 1000[query_rewrite] keys:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Apply the rewrite rules on the query path. |
rules | array | [] | Ordered rewrite rules ([[query_rewrite.rules]]). |
Each [[query_rewrite.rules]] entry (first matching transformation is applied):
| Key | Type | Description |
|---|---|---|
match_table | string | Apply to queries referencing this table. |
match_regex | string | Apply to queries matching this regex. |
replace_table_with | string | Rewrite match_table → this table name. |
append_where | string | Append AND <expr> to the WHERE clause. |
add_limit | u32 | Add LIMIT n to an unbounded query. |
Multi-Tenancy ([multi_tenancy])
Per-tenant row isolation via injected predicates. Only active with the multi-tenancy
feature and enabled = true.
[multi_tenancy]enabled = trueidentify_by = "application_name"tenant_column = "tenant_id"tenant_tables = ["orders", "invoices"]tenants = ["acme", "globex"]| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enforce per-tenant row isolation. |
identify_by | string | "application_name" | Connection attribute naming the tenant: a startup parameter name (e.g. application_name, user) or the literal database. |
tenant_column | string | "tenant_id" | The row-level tenant column injected into queries. |
tenant_tables | array | [] | Tables that get the tenant filter injected; others pass through. |
tenants | array | [] | Known tenant ids. |
Schema Routing ([schema_routing])
Route analytical (OLAP) queries to a dedicated node. Only active with the
schema-routing feature and enabled = true.
[schema_routing]enabled = trueanalytics_node = "analytics-1"| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Route aggregations / GROUP BY / window-function queries to a node. |
analytics_node | string | "" | name of the node analytical queries route to. |
Authentication ([auth]) and HBA Rules ([[hba]])
Client-side authentication mode and pg_hba-style admission.
[auth]mode = "scram"auth_file = "/etc/heliosproxy/userlist.txt"
[[hba]]action = "allow"user = "all"database = "all"address = "10.0.0.0/8"
[[hba]]action = "reject"user = "all"database = "all"address = "all" # trailing rule = default-deny[auth]:
| Key | Type | Default | Description |
|---|---|---|---|
mode | string | "passthrough" | passthrough relays client auth to the backend; scram makes the proxy terminate SCRAM-SHA-256 against auth_file. |
auth_file | string | (none) | Path to a pgbouncer-style user list (user:secret, secret = plaintext or a SCRAM-SHA-256$... verifier). Required when mode = "scram". |
[[hba]] rules are evaluated in order; the first rule whose user, database, and
address all match decides the outcome. If no rule matches, the connection is
admitted (add a trailing reject … all/all/all for default-deny):
| Key | Type | Default | Description |
|---|---|---|---|
action | string | — | allow or reject. |
user | string | "all" | Matching PostgreSQL user, or all. |
database | string | "all" | Matching database, or all. |
address | string | "all" | all, a bare IP, or a CIDR (e.g. 10.0.0.0/8, ::1/128). |
WASM Plugins ([plugins])
Plugin subsystem (a single [plugins] table, not an array of [[plugins]]). Only
consumed with the wasm-plugins feature; strictly opt-in.
[plugins]enabled = trueplugin_dir = "/etc/heliosproxy/plugins"hot_reload = falsememory_limit_mb = 64timeout_ms = 100max_plugins = 20fuel_metering = truefuel_limit = 1000000kv_max_value_bytes = 65536kv_max_keys_per_plugin = 1024kv_max_plugins = 256kv_max_total_bytes = 67108864# trust_root = "/etc/heliosproxy/plugin-keys"| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enable the plugin subsystem. |
plugin_dir | string | "/etc/heliosproxy/plugins" | Directory scanned for .wasm plugins at startup. |
hot_reload | bool | false | Watch plugin_dir and reload plugins on change. |
memory_limit_mb | usize | 64 | Memory limit per plugin instance. |
timeout_ms | u64 | 100 | Execution timeout per hook call. |
max_plugins | usize | 20 | Maximum concurrently-loaded plugins. |
fuel_metering | bool | true | Enable per-call CPU-cycle (fuel) metering. |
fuel_limit | u64 | 1000000 | Fuel units allowed per hook call when metering is on. |
kv_max_value_bytes | usize | 65536 | Max bytes for a single plugin-KV key OR value (via kv_set or PUT /admin/kv/<plugin>/<key>); 0 = unlimited. A write past this cap is rejected (kv_set returns -1; the admin endpoint returns 413). |
kv_max_keys_per_plugin | usize | 1024 | Max distinct keys per plugin KV namespace; 0 = unlimited. Overwriting an existing key never trips the cap. |
kv_max_plugins | usize | 256 | Max distinct plugin KV namespaces that may exist at once; 0 = unlimited. Bounds how many <plugin> namespaces PUT /admin/kv/<plugin>/<key> can create, so a token-holder cannot exhaust memory by writing to unboundedly-many namespace names. Writing to an already-present namespace never trips the cap; deleting a namespace’s last key frees its slot. |
kv_max_total_bytes | usize | 67108864 (64 MiB) | Max TOTAL retained bytes across ALL plugin KV namespaces (each entry’s key + value bytes plus each live namespace’s name bytes); 0 = unlimited. The single backstop that bounds the whole KV footprint regardless of the per-axis product kv_max_plugins × kv_max_keys_per_plugin × kv_max_value_bytes (which can otherwise reach tens of GiB), so a token-holding PUT /admin/kv/<plugin>/<key> caller cannot drive the proxy to an OOM. A write past this cap is rejected (kv_set returns -1; the admin endpoint returns 413). |
trust_root | string | (none) | Ed25519 trust-root directory. When set, every .wasm requires a sidecar .sig verifying against a *.pub in this directory; when omitted, signatures are not checked. |
MCP Agent Gateway ([mcp]) and Agent Contracts ([[agent_contracts]])
Native MCP server exposing query / list_tables / explain tools. Disabled by
default.
[mcp]enabled = truelisten_address = "127.0.0.1:9092"backend_host = "127.0.0.1"backend_port = 5432backend_user = "postgres"# backend_password = "..."# backend_database = "app"read_only = true# contract = "reporting-agent"auth_token = "${MCP_TOKEN}"query_timeout_ms = 30000max_concurrent_requests = 64
[[agent_contracts]]id = "reporting-agent"read_only = trueallowed_verbs = ["SELECT"]allowed_tables = ["orders", "invoices"]require_limit = truemax_rows = 1000[mcp]:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Serve the MCP JSON-RPC endpoint. |
listen_address | string | "127.0.0.1:9092" | HTTP listen address for MCP. |
backend_host | string | "127.0.0.1" | Backend the tool SQL runs against. |
backend_port | u16 | 5432 | Backend port. |
backend_user | string | "postgres" | Backend user. |
backend_password | string | (none) | Backend password. |
backend_database | string | (none) | Backend database. |
read_only | bool | true | Refuse write/DDL — agents get a read-only surface. |
contract | string | (none) | Name of an [[agent_contracts]] entry to enforce on every tool call. |
auth_token | string | (none) | Bearer token required on every MCP request. Absent = open, so set this for any non-loopback deployment — MCP exposes SQL and must not be anonymous off localhost. |
query_timeout_ms | u64 | 30000 | Per-tools/call backend execution timeout, milliseconds. Was a hardcoded 30 s. Must be > 0. |
max_concurrent_requests | usize | 64 | Maximum tools/call requests admitted concurrently; beyond this the gateway answers a JSON-RPC error (code -32000) instead of queuing indefinitely. initialize/ping/tools/list and notifications are never gated. Must be > 0. |
Each [[agent_contracts]] entry (scoped grants, referenced by id from [mcp] contract):
| Key | Type | Default | Description |
|---|---|---|---|
id | string | — | Identifier matched against the agent. |
read_only | bool | true | Reject write/DDL statements. |
allowed_verbs | array | (none) | If set, only these SQL verbs are allowed (upper-case). |
allowed_tables | array | (none) | If set, only these tables may be referenced. |
denied_tables | array | [] | Tables that may never be referenced (takes precedence over allow). |
require_predicate_on | array | [] | Predicates that must be present when a named table is touched. |
require_limit | bool | false | Require a LIMIT on SELECTs. |
max_rows | u64 | (none) | Suggested/enforced row cap. |
HTTP SQL Gateway ([http_gateway])
Neon-serverless-driver-compatible POST /sql endpoint. Disabled by default.
[http_gateway]enabled = truelisten_address = "127.0.0.1:9093"backend_host = "127.0.0.1"backend_port = 5432backend_user = "postgres"# backend_password = "..."# backend_database = "app"auth_token = "${HTTP_GW_TOKEN}"query_timeout_ms = 30000max_concurrent_requests = 64| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Serve the HTTP SQL gateway. |
listen_address | string | "127.0.0.1:9093" | HTTP listen address. |
backend_host | string | "127.0.0.1" | Backend host. |
backend_port | u16 | 5432 | Backend port. |
backend_user | string | "postgres" | Backend user. |
backend_password | string | (none) | Backend password. |
backend_database | string | (none) | Backend database. |
auth_token | string | (none) | Optional Bearer token required on requests. |
query_timeout_ms | u64 | 30000 | Per-request backend execution timeout, milliseconds. Was a hardcoded 30 s. Must be > 0. |
max_concurrent_requests | usize | 64 | Maximum /sql requests admitted concurrently; beyond this the gateway answers 503 Service Unavailable with a Retry-After header instead of queuing indefinitely (/health is never gated). Must be > 0. |
GraphQL Gateway ([graphql_gateway])
GraphQL-to-SQL gateway on a separate HTTP listener. Only active with the
graphql-gateway feature and enabled = true.
[graphql_gateway]enabled = truelisten_address = "0.0.0.0:9091"backend_host = "127.0.0.1"backend_port = 5432backend_user = "postgres"# backend_password = "..."# backend_database = "app"# auth_token = "..."query_timeout_ms = 30000max_concurrent_requests = 64
[[graphql_gateway.tables]]name = "orders"columns = ["id", "customer_id", "total", "created_at"][graphql_gateway]:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Serve the GraphQL gateway. |
listen_address | string | "0.0.0.0:9091" | HTTP listen address. |
backend_host | string | "127.0.0.1" | Backend host. |
backend_port | u16 | 5432 | Backend port. |
backend_user | string | "postgres" | Backend user. |
backend_password | string | (none) | Backend password. |
backend_database | string | (none) | Backend database. |
auth_token | string | (none) | Optional Bearer token required on requests. |
tables | array | [] | Tables exposed as GraphQL types ([[graphql_gateway.tables]]). |
query_timeout_ms | u64 | 30000 | Per-request backend execution timeout, milliseconds. Was a hardcoded 30 s. Must be > 0. |
max_concurrent_requests | usize | 64 | Maximum GraphQL requests admitted concurrently; beyond this the gateway answers 503 Service Unavailable with a Retry-After header instead of queuing indefinitely (/health is never gated). Must be > 0. |
Each [[graphql_gateway.tables]]: name (string) and columns (array of strings).
Traffic Mirror ([mirror])
Continuously mirror a sampled share of live (simple-query) writes to a secondary backend, off the client hot path. Disabled by default; the on-ramp to a PG→Nano migration mirror.
[mirror]enabled = truesample_rate = 1.0writes_only = truequeue_size = 10000backend_host = "127.0.0.1"backend_port = 5432backend_user = "postgres"# backend_password = "..."# backend_database = "app"# source_host / source_port / source_user / source_password / source_database| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Mirror eligible statements to the secondary. |
sample_rate | f64 | 1.0 | Fraction of eligible statements to mirror (0.0–1.0). |
writes_only | bool | true | Mirror only write/DDL statements; false mirrors all simple queries. |
queue_size | usize | 10000 | Bounded queue depth; when full, statements are dropped (and counted) rather than blocking. |
backend_host | string | "127.0.0.1" | Mirror-target host. |
backend_port | u16 | 5432 | Mirror-target port. |
backend_user | string | "postgres" | Mirror-target user. |
backend_password | string | (none) | Mirror-target password. |
backend_database | string | (none) | Mirror-target database. |
source_host / source_port / source_user / source_password / source_database | — | localhost / 5432 / postgres / none / none | Source (primary) connection used by POST /api/migration/snapshot to bootstrap the secondary. |
Edge / Geo Proxy ([edge])
Two-region result caching. A home-role proxy is authoritative (routes writes, caches
reads, broadcasts SSE invalidations); an edge-role proxy serves reads from a local
cache and forwards misses/writes to the home. Disabled by default. Parsed on every
build, but enabled = true requires the edge-proxy compile-time feature (validation
rejects it otherwise).
[edge]enabled = truerole = "edge" # "home" (default) or "edge"home_url = "https://home-proxy:9090" # edge: home admin base URLauth_token = "${EDGE_HOME_TOKEN}" # edge: home admin bearerallow_insecure_home_url = falsedefault_ttl_secs = 60max_entries = 10000max_total_bytes = 268435456max_edges = 32liveness_window_secs = 120subscribe_gc_secs = 30region = "eu-west"edge_id = "edge-a"| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master switch; requires the edge-proxy feature to enable. |
role | string | "home" | home (authoritative) or edge (cache-first). |
home_url | string | "" | (edge) Home proxy admin base URL the edge subscribes to. Required for role = "edge". |
auth_token | string | "" | (edge) Home admin bearer for the invalidation subscription. When set, home_url must be https:// unless allow_insecure_home_url = true. |
allow_insecure_home_url | bool | false | (edge) Allow presenting auth_token to a plain-http home_url (private links only). |
default_ttl_secs | u64 | 60 | Default TTL for cache entries when the home supplies none. Must be ≥ 1 when edge is enabled. |
max_entries | usize | 10000 | Cache entries before LRU eviction. |
max_total_bytes | usize | 268435456 | Aggregate retained-payload budget (since 1.9.0): response bodies plus per-entry overhead. On insert the cache evicts LRU entries until the retained payload fits. 0 disables the budget (entry count x per-entry ceiling then bounds memory). |
max_edges | usize | 32 | (home) Maximum simultaneously-registered edges. |
liveness_window_secs | u64 | 120 | (home) Edges not seen within this window are GC-pruned. Keep comfortably above ~45s. Must be ≥ 1 when edge is enabled. |
subscribe_gc_secs | u64 | 30 | (home) Registry GC sweep cadence. Must be ≥ 1 when edge is enabled. |
region | string | "" | (edge) Region label reported when subscribing. |
edge_id | string | "" | (edge) Stable registration id (empty → edge-<pid>). |
An edge-role proxy also requires at least one [[nodes]] entry pointing at the home’s
PG-wire listener (its data plane), and cannot be combined with [cache] enabled = true
(the query-result cache does not receive edge invalidations).
Instant Branch Databases ([branch])
Provision CREATE DATABASE <branch> TEMPLATE <base> clones through the proxy. Disabled
by default.
[branch]enabled = truebackend_host = "127.0.0.1"backend_port = 5432admin_user = "postgres" # a role with CREATEDBadmin_password = "${PGPASSWORD}"admin_database = "postgres" # maintenance DB for CREATE/DROP DATABASEbase_database = "postgres" # default template when a request omits `base`| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enable branch-database provisioning. |
backend_host | string | "127.0.0.1" | Backend host. |
backend_port | u16 | 5432 | Backend port. |
admin_user | string | "postgres" | Role with CREATEDB privilege. |
admin_password | string | (none) | Password for admin_user. |
admin_database | string | "postgres" | Maintenance database to issue CREATE/DROP DATABASE against. |
base_database | string | "postgres" | Default template database when a request omits base. |
Configuration Validation
At startup the proxy validates the loaded config and refuses to start if:
- No backend nodes are configured.
- No node has
role = "primary". pool.max_connections < pool.min_connections.health.check_interval_secs = 0.admin_addressis non-loopback butadmin_tokenis unset andadmin_allow_insecure = false(see Admin API Security).edge.enabled = truewithout theedge-proxyfeature, or anedgerole missing itshome_url, or its zero-value timing knobs, or combined with[cache].
Invalid configurations produce a descriptive error and a non-zero exit code. Unknown top-level keys are warned, not rejected (see Unknown Keys).
Complete Example
Ready-to-use examples live in config/proxy.example.toml, config/proxy.full.toml,
config/proxy.postgres.toml, and the working scripts/regress/*.toml files.
# HeliosProxy configuration example.
listen_address = "0.0.0.0:6432"admin_address = "127.0.0.1:9090"# admin_token = "${ADMIN_TOKEN}"tr_enabled = truetr_mode = "session"write_timeout_secs = 30
[pool_mode]mode = "transaction"max_pool_size = 100prepared_statement_mode = "track"skip_clean_reset = true
[pool]min_connections = 5max_connections = 100idle_timeout_secs = 300max_lifetime_secs = 1800acquire_timeout_secs = 30test_on_acquire = true
[load_balancer]read_strategy = "least_connections"read_write_split = truelatency_threshold_ms = 50
[health]check_interval_secs = 5check_timeout_secs = 3failure_threshold = 3success_threshold = 2check_query = "SELECT 1"
[[nodes]]host = "db-primary.internal"port = 5432role = "primary"weight = 100enabled = truename = "primary"
[[nodes]]host = "db-standby-1.internal"port = 5432role = "standby"weight = 100enabled = truename = "standby-1"
[tls]enabled = falsecert_path = "/etc/heliosproxy/server.crt"key_path = "/etc/heliosproxy/server.key"require_client_cert = falseSee Also
Transaction Replay: the re-executable read subset (since 1.7.0)
tr_mode = select and transaction re-execute an interrupted statement on the
replacement backend only when its outcome on the failed backend cannot have had a
side effect. The proxy decides lexically, without a catalog round trip:
- Statement shape:
SELECT,VALUES,TABLE,SHOW,EXPLAIN(withoutANALYZE),COPY … TO, or aWITHwhose CTEs contain noINSERT/UPDATE/DELETE/MERGE. - No
INTO(creates a table). - Every syntactic function call — an identifier followed by
(outside strings, quoted identifiers, dollar quotes and comments — must be a PostgreSQL built-in from the proxy’s side-effect-free list (aggregates, string/math/date/JSON/array functions,pg_sleep,random,now,current_setting, size/introspection helpers) or listed intr_read_functions. Schema qualification is ignored (pg_catalog.lower(...)islower). - A call through a quoted identifier (
"MyFn"(...)) is never eligible: its case is opaque to the policy.
Everything else — user-defined functions, nextval/setval/currval/lastval,
pg_notify, set_config, pg_advisory_*, txid_current, setseed, large-object
and replication functions — makes the statement opaque: on an unknown outcome the
client receives SQLSTATE 08007 and the proxy does not run it again. Inside an
explicit transaction an opaque statement also marks the transaction as containing
a possible write, so select mode will not replay it. Nondeterministic but
side-effect-free calls are allowed because an unknown-outcome autocommit read
published nothing to the client before the fault.
Session state across a failover (since 1.7.0)
SET/RESET issued through the simple query protocol are tracked per session and
replayed, in order, onto the replacement backend when the session re-homes. The
tracking is transactional, mirroring PostgreSQL: a SET inside an explicit
transaction takes effect for restore only when that transaction commits; ROLLBACK
discards it; ROLLBACK TO SAVEPOINT discards the SETs made after the savepoint
(RELEASE keeps them); RESET name, RESET ALL and DISCARD ALL inside a
transaction are likewise deferred until commit. SET LOCAL, SET TRANSACTION and
SET CONSTRAINTS are transaction-scoped and never tracked. Variables are identified
by name (SET TIME ZONE is timezone, SET SCHEMA is search_path, SET NAMES
is client_encoding), so repeated SETs of one variable cost one slot. Extended-
protocol SETs, SQL-level PREPARE, temporary tables and cursors are not restored.
(Extended-protocol named prepared statements are re-prepared lazily on the
replacement backend — a separate mechanism from this restore set.)
Replay verification and the recovery deadline (since 1.7.0)
While a transaction is recorded for tr_mode = transaction replay, the proxy hashes
each statement’s response frames (RowDescription, DataRow, CommandComplete,
EmptyQueryResponse) up to tr_max_observation_bytes. When the transaction is
replayed on the replacement backend, each response is hashed the same way; if any
digest differs — the data the client already saw is not what the new backend would
have returned — the replay is rolled back and the client receives 40001 for the
interrupted statement. Transactions opened at SERIALIZABLE or REPEATABLE READ
(explicitly, or through default_transaction_isolation) are never replayed: a
replay cannot reproduce the old snapshot.
One deadline, write_timeout_secs, bounds the whole recovery: waiting for a
healthy primary, connecting and authenticating, restoring session SETs, replaying
the transaction and re-executing the interrupted statement.