feat: Neon-compatible HTTP /sql endpoint#991
Conversation
Two-layer rollout flag plus pool/limit knobs for the upcoming HTTP /sql endpoint (issue supabase#152). Fail-closed default (enabled: false) in config.exs; per-env overrides in dev/test; env-var-driven overrides in runtime.exs. Refs: supabase#152
Pure-function URL parser that extracts user, password, database, and the candidate Supabase external_id (from `postgres.<id>` username form) from a standard Postgres connection URL. Used by the upcoming NeonAuth plug to feed Supavisor.Tenants.get_user_cache/4. Refs: supabase#152
Encodes Postgrex-decoded Elixir terms back to Postgres text representation indexed by column OID, for the Neon /sql wire format. Covers bool, int, float (incl. NaN/Infinity), bytea (hex), numeric (Decimal), date/time/ timestamp/timestamptz/interval, uuid, json/jsonb, and arrays with proper quoting/escaping rules. Unknown OIDs fall back to to_string with a one-time warn-log per OID via :persistent_term. Refs: supabase#152
…on JSON
ErrorMapper.to_neon_error/1 converts any error term encountered during
HTTP /sql execution into {http_status, body} where body mirrors the
fields read by @neondatabase/serverless NeonDbError (code, severity,
detail, hint, position, internalQuery, where, schema, table, column,
dataType, constraint, file, line, routine). nil keys are dropped.
Status codes: 401 for invalid_password/invalid_authorization, 403 for
insufficient_privilege, 503 for ConnectionError/timeout/circuit_open,
413 for row_limit_exceeded, 400 otherwise.
Refs: supabase#152
Validates Neon-Batch-Isolation-Level / Read-Only / Deferrable HTTP
headers against a strict allow-list (rejects injection attempts) and
emits a single `SET TRANSACTION ...` SQL fragment issued at the start
of a batched transaction. Returns {:ok, nil} when no batch headers are
present so callers can skip the SET roundtrip.
Refs: supabase#152
ResponseBuilder.build_single/2 produces the canonical Neon /sql body
(command, rowCount, fields[], rows[]) from a {%Postgrex.Query{},
%Postgrex.Result{}} pair, honoring the Neon-Array-Mode header
(rows as positional arrays vs column-name-keyed objects). build_batch/2
wraps N single results under a 'results' key for batch transactions.
Field metadata follows Neon shape: name + dataTypeID (OID) +
dataTypeSize/Modifier (-1 since we don't expose them) + format "text".
Refs: supabase#152
PoolSpec.build/1 produces the Postgrex.start_link/1 keyword list dialing
127.0.0.1:proxy_port_transaction with the tenant's user/password and
backoff_type: :stop. PoolSpec.key/3 derives the registry key from
{tenant_external_id, user, sha256(password)} so password rotation
naturally lands in a new pool.
PoolRegistry is a GenServer that owns the named ETS table
:http_sql_pools, lazily starts pools on checkout via the configured
starter fn, monitors pool pids, evicts on idle TTL via periodic sweep,
caps total pools and evicts LRU on overflow. starter/terminator are
overridable via Application env for tests (fake child processes).
Wired into Supavisor.Application after Supavisor.Vault, before
SupavisorWeb.Endpoint.
Refs: supabase#152
Telemetry.request_span/2 wraps a controller body in :telemetry.span([:supavisor, :http_sql, :request], ...) so :start / :stop / :exception events fire with consistent metadata; the :stop event carries response_rows/response_bytes/status_code automatically. Telemetry.pool_checkout/3 emits pool checkout events with hit?/miss? tag. PromExPlugin registers distribution+counter metrics for the four event families (request, pool checkout, pool evict, pool start) under the same bucket schema as the existing Tenant plugin, then is added to Supavisor.Monitoring.PromEx.plugins/0 so the new metrics appear under /metrics automatically. Refs: supabase#152
HttpSql.execute/4 runs a single Postgrex.prepare_execute against the pooled connection for ctx and returns a Neon-shaped body via ResponseBuilder. HttpSql.execute_batch/4 wraps N queries in one transaction with the optional SET TRANSACTION fragment from Transaction.build/1, rolls back on the first error, enforces max_response_rows per query. Integration tests against the live dev_tenant cover: simple SELECT, parameterized SELECT, NULL, array_mode true/false, syntax error propagation, batch happy path, Neon-Batch-Read-Only header effect on transaction_read_only, rollback on error, invalid isolation header rejection. Tagged :integration so excluded from default mix test. Refs: supabase#152
Plug pipeline that gates HTTP /sql requests on global feature flag, header presence, URL parse, tenant lookup via Tenants.get_user_cache/4 (splitting role from external_id in the Supabase username convention postgres.<id>), IP ban via CircuitBreaker, per-tenant feature flag http_sql, and tenant allow_list CIDR check via HandlerHelpers.filter_cidrs/2. Resolves remote IP from x-forwarded-for when present. On success assigns http_sql_ctx for downstream consumption by SqlController. On failure halts with a Neon-shaped JSON error body via ErrorMapper. Refs: supabase#152
SqlController.execute/2 dispatches single ({query, params}) and batch
({queries: [...]}) JSON bodies to HttpSql.execute / execute_batch,
reads Neon-Array-Mode and Neon-Batch-* headers, returns Neon-shape
JSON or maps errors via ErrorMapper. Wrapped in Telemetry.request_span.
Router gets an :http_sql pipeline (JSON parser with body-size cap from
config) and POST /sql.
PoolSpec: backoff_type :stop → :exp so the Postgrex pool self-heals
across transient supavisor pool restarts (e.g. tenant config updates).
Auth failures stay bounded by the tenant-level CircuitBreaker.
Tests: single/batch happy paths, array_mode, syntax error, malformed
body, Neon-Batch-Read-Only, invalid isolation, feature-gate kill
switch (global + per-tenant).
Refs: supabase#152
Phoenix-router-driven E2E test (http_sql_e2e_test.exs) exercises the full POST /sql pipeline (pipeline → plug → controller → facade → encoder) asserting Neon-shaped responses for scalar SELECT, parameter binding, NULL, bool, date, aggregate, batch transactions with READ ONLY, and Postgres error code propagation. neon_http_sql/index.js is a manual deliverable that runs the upstream @neondatabase/serverless neon() HTTP helper against a live Supavisor instance with fetchEndpoint redirected to our /sql, proving drop-in compatibility. README documents the manual run procedure for release verification. Refs: supabase#152
The @neondatabase/serverless driver serializes every parameter as a JSON string regardless of original type (42 → "42", true → "true"). Postgrex's binary-bind then rejects "42" for an int4 column with DBConnection.EncodeError. Add ParamCoercer which converts string values to Elixir native types based on param_oids from Postgrex.prepare/3. The facade now does prepare → coerce → execute instead of prepare_execute. Covers bool, int2/4/8, float4/8 (incl. NaN/Infinity), numeric, bytea, text family, date/time/timestamp/timestamptz, uuid, json/jsonb. Unknown OIDs pass through unchanged. Refs: supabase#152
Node's built-in fetch (which @neondatabase/serverless uses) defaults Content-Type to text/plain for string bodies. Plug.Parsers short- circuits when Content-Type doesn't match any registered parser and never invokes a custom adapter, so this can't be solved by writing one. Add NeonBodyParser — a standalone Plug mounted before Plug.Parsers in the endpoint. It reads and JSON-decodes the body when path_info starts with "sql" and Neon-Connection-String is present, regardless of Content-Type. Setting body_params makes the downstream Plug.Parsers short-circuit without re-reading the body. A body_params: Unfetched guard keeps it a no-op when an upstream plug already parsed the body. 19 unit tests cover trigger predicate, Content-Type tolerance, error handling, and the body_params guard. Refs: supabase#152
The Date test compared rows[0].d.toString() to a fixed yyyy-mm-dd string. pg-types decodes date OID 1082 with the local-time Date constructor, so .toString() yields a locale-formatted string and .toISOString() shifts to UTC (off-by-one day in positive offsets). Replace with local-component accessors (getFullYear/Month/Date) so the test is invariant of the machine timezone. Bug was in the test only; the server's text-format date encoding is correct. Refs: supabase#152
- Row cap returns via Postgrex.rollback instead of an uncaught throw
(which crashed the controller and leaked the conn into the crash log).
- Drop the unsupported :conn kwarg from Plug.Parsers.RequestTooLargeError
— it was a KeyError on the raise path.
- Key the CircuitBreaker :auth_error bucket by the real HTTP IP, not the
loopback peer of our Postgrex pool. The old code would have eventually
banned {tenant, 127.0.0.1} and self-DoS'd HTTP traffic.
- Wire HTTP_SQL_MAX_QUERY_BYTES into NeonBodyParser :length. It was read
from config but never passed anywhere; effective cap was 8 MiB.
- Hook PoolRegistry.evict_tenant/1 into Supavisor.del_all_cache/1 so
admin actions evict HTTP-SQL pools immediately, not after idle TTL.
- trap_exit in PoolRegistry to survive linked-process deaths.
- Catch :exit in the facade on TOCTOU pool death; return Neon-shape 503
instead of crashing into a generic 500.
- Call Postgrex.Interval.to_string/1 directly — there's no String.Chars
impl, so the old to_string(v) would have raised on the first interval.
- Bucket the PromEx batch_size tag ("1", "2-10", "11-100", "101-1000",
"1000+") to avoid unbounded label cardinality.
Refs: supabase#152
- NeonAuth returns 401 on Authorization: Bearer instead of silently ignoring it; v1 doesn't support Neon's RLS-JWT path. - ErrorMapper fallback logs the unknown term and returns a generic message instead of inspect/2-ing it into the response body. - Drop sha256(password) from PoolRegistry telemetry tags. - Single-query path no longer wraps in Postgrex.transaction. Saves the BEGIN+COMMIT round-trips. - Async pool termination via the existing Supavisor.PoolTerminator so the registry isn't blocked for up to 5s on each eviction. - O(1) pid→key reverse map in PoolRegistry for the DOWN handler. - Drop :persistent_term.put/2 for unknown-OID warnings — it triggers a global GC and grows monotonically. Emit a :telemetry event instead. - max_response_bytes cap: serialized response over the limit returns 413 response_too_large. Row-count alone wasn't enough. - NeonAuth trusts X-Forwarded-For only when conn.remote_ip is in the configured :trusted_proxies CIDR list. - ParamCoercer handles OID 1186 (interval) and array OIDs; the rest previously crashed Postgrex with DBConnection.EncodeError. Tests: JWT 401, sanitized fallback, interval parsing, array coercion. Refs: supabase#152
- Telemetry: result_metadata/1 reads the actual Neon-shape body keys
("rowCount" / "results"). The PromEx response_rows histogram now
reports real values instead of permanently flat-zero.
- SqlController.audit_log/3 emits a one-line Logger entry per error
response. 401s log at warn so SREs can spot brute-force attempts.
- timestamptz encoder drops the redundant DateTime.shift_zone! and a
literal no-op regex.
- NeonAuth: direct struct pattern matches instead of __struct__
reflection; drop unused ctx.tenant / ctx.user_record.
- Float coercion rejects partial parses like "3.14garbage" instead of
silently accepting the prefix.
- Stale comments fixed in router.ex and pool_spec.ex; docs updated for
max_query_bytes / max_response_bytes / trusted_proxies.
Refs: supabase#152
Plug.Cowboy.Conn.read_req_body/2 (the underlying adapter) returns
{:ok, body, _} as soon as Cowboy signals end-of-stream, regardless of
how much body has accumulated. Its :length cap is only a guard for
streaming-without-end; bodies with a finite Content-Length larger
than :length are still returned in full.
Confirmed during manual verification: a 1.5 MiB request body went
through with :length: 1_048_576 set on NeonBodyParser.
Resolved by checking byte_size(body) > cap in do_parse and raising
Plug.Parsers.RequestTooLargeError. Now a 1.5 MiB body against a 1 MiB
cap returns 413 (verified via curl).
Test added to neon_body_parser_test.exs asserting the post-read cap
path with a tiny :length value.
Refs: supabase#152
Five scenarios that wouldn't surface in pure-function unit tests: - evict_tenant E2E: Supavisor.del_all_cache/1 → PoolRegistry.evict_tenant/1. Verified via both direct call and the real update_tenant/2 path. - TOCTOU pool death: facade catches generic :exit reasons (not just :noproc / :timeout) so a pool that dies between checkout and Postgrex.prepare returns a Neon-shape 5xx. - Telemetry pipeline: :request, :stop carries non-zero response_rows; :pool, :checkout fires with the expected hit?/miss tag. - Audit log: capture_log/2 asserts events fire without leaking the password into the log line. - Brute-force CircuitBreaker is left SKIPPED here — robust detection needs synchronous SCRAM pre-validation in NeonAuth, done in the next commit. Also extends maybe_record_auth_failure to match DBConnection.ConnectionError carrying PG auth SQLSTATEs in its message, so the brute-force gate fires when the upstream pool surfaces the SCRAM error inline. Refs: supabase#152
Closes the brute-force defense gap from the previous commit. NeonAuth verifies the supplied password against the cached ValidationSecrets SCRAM stored_key locally before checking out a Postgrex pool — no round-trip to Postgres. The previous design relied on the facade detecting Postgrex.Error invalid_password after the call. That signal is unreliable under load: a bad-password Postgrex pool with backoff_type: :exp produces a sequence of timeouts and generic DBConnection.ConnectionError, with the SCRAM-fail buried in Postgrex's connect log. The new approach re-derives stored_key from the supplied password + cached salt + iterations and compares it constant-time against the cached stored_key. Removed the now-redundant maybe_record_auth_failure/2 in the facade. Re-enabled the previously skipped brute-force integration test. Manual end-to-end: 9 bad-password attempts → 401 each, 10th → 503 (breaker trips), 11th with correct password → still 503 circuit_open. Refs: supabase#152
Short page under Connecting → HTTP /sql: how to enable (global env + per-tenant flag), single-query and batch request/response shapes, drop-in usage with @neondatabase/serverless, the auth/CircuitBreaker model, env-var table, and the error code reference. Refs: supabase#152
28832eb to
72fae89
Compare
|
Hi, @MorganaFuture, thanks for the PR, that's a lot of work! It will take some time to review. I'll do a first pass today, but there will probably be more iterations. |
There was a problem hiding this comment.
Again, thanks for putting the effort to write this!
I think doing this via Postgrex is a mistake, and makes the code more complex than it should be. Have you considered doing it via an alternative client worker? Imagine the following:
(1) HTTP handler receives request
(2) Spawn a one-off client handler (I think it would not be possible to reuse the ClientHandler module here, but you may implement an alternative ClientHandler inspired on it, e.g.: HTTPClientHandler) that does the regular operations: spawn pool if it doesn't exist (if it does, reuse it), check out a connection, and send the SQL to the database. This will also help us to enforce limits (e.g.: client handler limit)
(3) You will be able to use the simple query protocol here, so you won't need to coerce parameters, etc.
(4) I'm not 100% sure yet of how the query results should be handled, either:
- they are forwarded directly to the client
- they are sent to the HTTP handler which performs necessary transformations and sends them to the client
Another alternative to explore: instead of a custom client handler, reuse the existing one like the WS module does.
| /** | ||
| * Manual smoke test: runs `@neondatabase/serverless` against a live | ||
| * Supavisor /sql endpoint. | ||
| * |
There was a problem hiding this comment.
Did you have issues running them via ExUnit, like the existing Neon websocket adapter?
There was a problem hiding this comment.
Nope, kept it manual during the dev process. Will fold it into ExUnit as part of the rewrite
@v0idpwn Thanks for feedback! Before I start to the rewrite, I have two question: The first is The second is the simple-query idea. The Neon driver sends query and params as separate JSON fields, and ?Q doesn't carry params, so going pure simple-query means inlining params into the SQL with escaping, which puts the injection boundary inside Supavisor. If we use extended protocol with text-format params instead (no binary, no typing), Postgres parses each string server-side and ParamCoercer still goes away. That feels like the right tradeoff. A couple of things I sorted out already so we don't go back and forth. The value encoder isn't going away completely bytea and arrays come out of libpq's text format in a different shape than Neon's JSON wants, so some reshaping has to stay. The limit gap is real; my pools currently sidestep Manager.subscribe, and on the rewrite I'd register through it so HTTP clients land in the same accounting as everything else. And ws_proxy is just a byte relay to the proxy port. I'm reading "reuse like WS does" as reuse of the internal checkout, not the WS pattern itself. Say if that's wrong. |
Currently, DbHandler handles either
My bad, you are right, we need to use the extended query protocol here! But, if we format the messages ourselves instead of relying on Postgrex, the extneded query protocol supports text-based parameters, see this |
yep, works cleanly.
Agreed, text-format Bind is the play. Starting on rewrite. Can you please assign issue #152 to me? |
DbHandler writes all client-bound bytes through a single helper
(HandlerHelpers.sock_send/2). Adding a {:proc, pid} clause routes
those bytes as Erlang messages instead of socket writes, so a process
without a real socket (the upcoming HTTP /sql client handler) can
consume backend traffic the same way TCP/SSL clients do.
setopts/2 and sock_close/1 get matching no-op clauses for the new
variant, and Supavisor.sock() gains a proc_sock type.
Hardcoding `Supavisor.ClientHandler.db_status/2` made DbHandler the only valid caller for transaction-mode ready-for-query signalling. The upcoming HTTP /sql handler is a different kind of caller — same contract, different module — so DbHandler now stores a `caller_module` in its state (default `Supavisor.ClientHandler`) and dispatches the RFQ signal through it. The fifth argument of `checkout/5` becomes a keyword list with `:timeout` and `:caller_module`. The previous integer-timeout form is kept as a backwards-compatible clause so existing callers in `client_handler.ex` need no changes. `caller_module` is reset to the default whenever the worker returns to `:idle` after a transaction-mode RFQ, mirroring the existing `caller`/`client_sock` reset.
Pure iodata builders for Parse, Bind, Describe, Execute, Sync, Close and simple Query. Bind is fixed to text format for every parameter and every result column — that is what the Neon driver sends on the wire and what lets the HTTP /sql path skip a binary type encoder. The length-field semantics are spelled out in the helper (`len/1`): it counts the 4-byte length itself plus payload, but excludes the 1-byte message-type tag. NULL parameters are sent as Int32(-1) with no value bytes, per the protocol spec. These builders are consumed by `Supavisor.HttpSql.ClientHandler` (coming in a later commit) which writes them straight to the upstream socket returned by `DbHandler.checkout/5`.
WireDecoder turns a backend wire-byte buffer (read from {:proc, pid}
:db_bytes messages) into the structured result the HTTP /sql response
builder consumes:
* `parse_prepare_response/1` — for the round-trip after
Parse + Describe(S) + Sync. Returns `%{param_oids, columns}`.
* `parse_execute_response/1` — for the round-trip after
Bind + Execute + Sync. Returns `%{columns, rows, command,
num_rows}`. Rows are lists of binaries (or nil for SQL NULL).
* `ready_for_query?/1` — lets the receive loop know when to stop
accumulating more chunks.
Both walk a list of `%Supavisor.Protocol.Server.Pkt{}` produced by
the existing `Server.decode/1` (so framing, tag mapping, and
RowDescription/ParameterDescription parsing are reused). DataRow and
CommandComplete are decoded inline here — Server.decode/1 leaves them
as raw payloads. Notice responses are ignored. ErrorResponse halts
and returns `{:error, %PgError{}}`.
`Supavisor.HttpSql.PgError` is a small exception struct that mirrors
the `%Postgrex.Error{postgres: %{...}}` shape so that ErrorMapper can
treat both with sibling clauses in a later commit.
`Supavisor.HttpSql.ClientHandler.run_query/4` is the new query
executor for the HTTP /sql path. Unlike `Supavisor.ClientHandler`
(gen_statem driven by a Ranch socket), this is a plain function that
runs in the Plug request process and reuses every existing piece of
Supavisor's pool plumbing:
* `Supavisor.start_dist/3` spawns or finds the tenant supervisor.
* `Supavisor.subscribe/2` enforces the tenant's max_clients limit
and registers this request with the Manager.
* `:poolboy.checkout/3` pulls a DbHandler worker.
* `DbHandler.checkout/5` hands the worker our `{:proc, self()}`
"socket" so backend response bytes arrive in this process's
mailbox as `{:db_bytes, _}` messages.
The query itself is a single extended-query round-trip:
`Parse + Bind + Describe(P) + Execute + Sync`, with text-format
parameters everywhere. No `ParamCoercer`, no prepare round-trip, no
Postgrex — exactly the shape @v0idpwn proposed for supabase#152 (the
extended-query protocol supports text-based parameters when the
messages are formatted manually).
Parameters arrive from `NeonBodyParser` as JSON scalars; they are
stringified into PG text format here. `Wire.bind/3` sends a single
format-code count of 0, which means "all parameters text" — so no
binary encoder is involved on either end.
NeonAuth gains a `db_user` field on `http_sql_ctx` (the role name
without the tenant suffix), which `ClientHandler.build_id/1`
consumes as `Supavisor.id(user: ...)` to match the pool registry's
keying.
Unit test exercises the `db_status/2` callback contract used by
DbHandler. End-to-end coverage against a real Postgres will land
once the facade is wired through (next commit).
The new HTTP /sql client handler (`Supavisor.HttpSql.ClientHandler`)
returns a structured map produced by `WireDecoder`, not a
`{%Postgrex.Query{}, %Postgrex.Result{}}` tuple. `ResponseBuilder`
gains a second `build_single/2` clause that pattern-matches on the
map shape:
%{columns: [%{name, oid}] | nil, rows, command, num_rows}
`build_batch/2` accepts mixed lists during the migration, so the
legacy executor and the new client can coexist in `build_batch`
calls without ceremony. Both clauses produce identical Neon JSON.
The legacy `{Postgrex.Query, Postgrex.Result}` clause will be deleted
once Postgrex is gone (commit 33).
`Supavisor.HttpSql.WireDecoder` returns `{:error, %PgError{}}` when
the backend emitted an `ErrorResponse` packet. Until now ErrorMapper
only knew about `%Postgrex.Error{}`, so wire-decoded errors would
fall into the generic 500 fallback.
The new `%PgError{}` clause:
* Maps SQLSTATE → HTTP status (28P01/28000 → 401, 42501 → 403,
everything else → 400), mirroring the existing Postgrex branch.
* Translates the single-letter PG `ErrorResponse` field codes to
the camelCase keys the @neondatabase/serverless driver expects
(per https://www.postgresql.org/docs/current/protocol-error-fields.html).
* Drops nil fields, matching the legacy clause.
Existing `%Postgrex.Error{}` clause stays in place for the legacy
executor; both go away together when Postgrex is removed in commit
33.
The single-query path now goes through
`Supavisor.HttpSql.ClientHandler.run_query/4`, which talks to the
existing Supavisor TenantSupervisor + Manager + poolboy + DbHandler
machinery using the new `{:proc, pid}` socket variant. No
Postgrex.prepare/execute, no fake-loopback 127.0.0.1 hop.
`check_row_cap/1` gains a clause for the new wire-format result map.
The Postgrex.Result clause stays around for the batch path until
commit 31 migrates it as well.
Telemetry: `pool_checkout` is still emitted so downstream PromEx
panels keep working; its `hit?` tag is forced to `:hit` because the
new path has no per-request pool cache lookup. Commit 32 splits this
into dedicated `start_dist` and `subscribe` events.
The legacy `run_query/4` private helper (Postgrex-based) is deleted.
`Supavisor.HttpSql.ClientHandler.run_batch/4` is the new batch
executor. It shares the request-scoped Manager subscription and a
single DbHandler worker across the whole batch:
1. start_dist + subscribe + poolboy.checkout + DbHandler.checkout
2. simple Query "BEGIN"
3. optional simple Query "SET TRANSACTION ..."
(built by Supavisor.HttpSql.Transaction.build/1 from
Neon-Batch-* headers)
4. for each query: Parse + Bind + Describe(P) + Execute + Sync
5. on first error: best-effort "ROLLBACK", surface the error
6. on success: "COMMIT", return the list of decoded results
`Supavisor.HttpSql.execute_batch/4` now calls this and applies the
row-cap across the whole batch via `check_batch_row_cap/1`.
The last Postgrex-based helpers in the facade (`run_batch/4`,
`prepare_and_execute/4`, `enforce_row_cap!/2`, the `checkout/1`
PoolRegistry hop) are deleted. The PoolRegistry module itself,
its supervision wiring, and the Supavisor.del_all_cache hook are
removed in commit 33.
With execute/4 and execute_batch/4 both routed through
`Supavisor.HttpSql.ClientHandler` (commits 30 and 31), nothing reads
from the old Postgrex-pool layer anymore.
Deleted modules:
* `Supavisor.HttpSql.PoolRegistry` — GenServer + ETS that managed
`Postgrex.start_link` pools keyed by `(tenant, user, pwd_hash)`.
Replaced by the standard Supavisor TenantSupervisor + Manager +
poolboy machinery.
* `Supavisor.HttpSql.PoolSpec` — builder for `Postgrex.start_link`
keyword lists. No longer needed; the Supavisor pool spec is
constructed by `Supavisor.TenantSupervisor`.
* `Supavisor.HttpSql.ParamCoercer` — workaround for Postgrex's
binary-bind path that demanded native Elixir types. With
text-format Bind we pass strings straight to the backend.
Wiring removed:
* Child spec for PoolRegistry in `Supavisor.Application.start/2`.
* `Supavisor.HttpSql.PoolRegistry.evict_tenant/1` hook in
`Supavisor.del_all_cache/1` — the standard Supavisor pool tree
is already invalidated by surrounding cache deletion + tenant
update flows.
Dead clauses in surviving modules:
* `Supavisor.HttpSql.ResponseBuilder.build_single/2` clause for
`{%Postgrex.Query{}, %Postgrex.Result{}}` — only the wire-format
map clause remains.
* `Supavisor.HttpSql.ErrorMapper.to_neon_error/1` clauses for
`%Postgrex.Error{}` — `%PgError{}` is the only DB-error source.
Tests removed/updated:
* `test/supavisor/http_sql/{pool_registry,param_coercer}_test.exs`
deleted along with their modules.
* `test/integration/http_sql_p1_test.exs` deleted (every scenario
targeted the removed PoolRegistry behaviour). Production-level
integration scenarios for the new path will land in commit 34.
* `test/integration/http_sql_e2e_test.exs` setup no longer
restarts PoolRegistry; tenant-feature-flag + global-enabled
bootstrapping is all that's needed.
* `test/integration/http_sql_facade_test.exs` updated to expect
`%Supavisor.HttpSql.PgError{}` instead of `%Postgrex.Error{}`.
* Postgrex-tuple-shape coverage stripped from
`test/supavisor/http_sql/{response_builder,error_mapper}_test.exs`.
… gauge
The `:pool, :evict` and `:pool, :start, :stop|:exception` events
were tied to the removed `Supavisor.HttpSql.PoolRegistry`. The new
path runs through the standard Supavisor tenant supervisor, which
already publishes pool-lifecycle telemetry — duplicating those
under `:supavisor, :http_sql, :pool, ...` would only confuse
dashboards.
What's added:
* `Telemetry.max_clients_rejected/2` and the matching
`[:supavisor, :http_sql, :max_clients_rejected]` event, emitted
by the facade when `Supavisor.subscribe/2` returns
`MaxConnectionsError`. PromEx exposes it as a counter so SREs
can alert on tenant saturation before users notice 429s.
* `result_metadata/1` reports `status_code: 429` for
`MaxConnectionsError` so the request-span histogram bucket
reflects rejections rather than misclassifying them as 500s.
What's removed:
* `supavisor_http_sql_pool_evict_count` and
`supavisor_http_sql_pool_start_duration` from the PromEx plugin.
* The `[:pool, :evict]` / `[:pool, :start, ...]` event lines from
the `@moduledoc` mapping table.
`pool_checkout` is kept and now covers the latency of
`start_dist + Manager.subscribe + poolboy.checkout +
DbHandler.checkout` — the value users care about for query
overhead. Its `hit?` tag is informational only (always `:hit` for
now); it can be repurposed if a pre-checkout cache layer is
introduced.
Per @v0idpwn's review comment, the JS smoke test now runs through the same `Supavisor.Integration.ExternalTest` driver that exercises `neondatabase/serverless` over WebSocket. The new describe block "neondatabase/serverless.js — HTTP /sql": * Flips `:enabled` and the per-tenant `http_sql` feature flag in `setup`, restoring both on `on_exit`. No `make dev` ceremony or curl PATCH against `/api/tenants/...` to enable the path. * Passes the HTTP endpoint URL via the `HTTP_SQL_ENDPOINT` env var so the JS file does not need to know which port the Endpoint listens on. The smoke script's header comment is updated to point at the ExUnit driver, with the manual-invocation recipe kept as a fallback for ad-hoc local debugging.
The "How it works" section now walks the rewrite path:
NeonAuth → ClientHandler.run_query → start_dist → Manager.subscribe
→ poolboy.checkout → DbHandler.checkout({:proc, self()}) → backend
bytes as Erlang messages → WireDecoder → ResponseBuilder
With no separate Postgrex pool, `HTTP_SQL_POOL_SIZE`,
`HTTP_SQL_POOL_MAX_TOTAL`, and `HTTP_SQL_POOL_IDLE_TTL_SECONDS` no
longer have a backing implementation. They're removed from
runtime.exs, config.exs, dev.exs, test.exs, and the docs env-var
table. Tenant pool sizing is now controlled by the existing tenant
`default_pool_size` / `max_clients` records.
A new "Saturation" section documents the 429 response and the
`max_clients_rejected` Prometheus counter, and the errors table
mentions 429 explicitly.
Audit caught a real bug: ResponseBuilder.encode_rows was piping every wire-format cell through ValueEncoder.encode/2, whose typed clauses (e.g. `encode(true, @Bool)`, `encode(%Date{} = v, @Date)`) were designed for Postgrex's binary-decoded Elixir-native inputs. With the new path, cells arrive as binaries (text-format from the backend), none of the typed clauses match, and every cell falls through to the `warn_unknown_oid/2` branch — firing a `[:supavisor, :http_sql, :value_encoder, :unknown_oid]` telemetry event per cell per request. The output happened to be correct (the fallback returns the binary unchanged) but the side-effect was wrong: the unknown_oid metric became meaningless noise, useless for actually detecting unknown OIDs. Root cause: `Wire.bind/3` issues every Bind with result-format-code = 0 (text), so the backend returns every value as a `pg-types`-parseable text string. That is exactly what the Neon driver consumes off the wire. Encoding is a no-op in this direction; we just need to do structural mapping (positional list or {column_name => cell} map per `Neon-Array-Mode`). ResponseBuilder.encode_rows is now a single zip/Map.new for keyed mode and identity for array mode. `ValueEncoder` (and its 209-line test file) is deleted: no callers remain after this commit. Unit tests for ResponseBuilder still pass — they treat cells as opaque binaries throughout, which now matches reality.
`Manager.subscribe/2` has a `node(manager) == node()` guard for
safety, so when `Supavisor.start_dist/3` returns a TenantSupervisor
on a remote node, the HTTP request used to crash with
FunctionClauseError. The TCP `Supavisor.ClientHandler` works around
this by falling back to `:proxy` mode (raw TCP byte-pipe to the
remote pool's `pg_proxy_internal` listener), but the HTTP path has
no peer socket to proxy to.
The fix uses node-to-node BEAM distribution:
* `subscribe/2` checks `node(sup)`. Local → `Supavisor.subscribe/1`
as before. Remote → `subscribe_remote/2`.
* `subscribe_remote/2` does `:erpc.call(pool_node, Supavisor,
:get_local_workers, [id])` to fetch the remote pool's Manager
and poolboy pids, then issues the subscribe via raw
`GenServer.call`, bypassing the guard. `Process.monitor/1`
(Manager's DOWN cleanup mechanism) works for remote pids, so
nothing about Manager's bookkeeping breaks.
* Subsequent `:poolboy.checkout/3` and `DbHandler.checkout/5` are
plain gen_server/gen_statem calls — they work transparently
across nodes.
* `HandlerHelpers.sock_send({:proc, pid}, bytes)` is just
`send(pid, {:db_bytes, _})` — Erlang distribution routes the
message back to our local node, no special handling needed.
Errors from unreachable remote nodes (network partition, peer
crash) surface as `{:error, {:remote_workers_unreachable, _}}` and
flow through `ErrorMapper` as 503s.
Multi-node integration coverage will land via
`Supavisor.Support.Cluster` peers in a follow-up commit; unit tests
can't easily fake a remote pid, so the local-path coverage stays
identical and the remote-path is covered by code review + the
existing `:integration` cluster scaffolding.
Adds a `Supavisor.HttpSql.FakeDbHandler` test double under
`test/support` that replicates the slice of `Supavisor.DbHandler`
the HTTP /sql client relies on: a `:gen_statem.call/3` checkout
shape, a scripted response queue, and `sock_send/2` echoes back to
the captured client socket. Tests can now drive the full
Parse → Bind → Execute → DataRow/CommandComplete/RFQ conversation
without booting a real Postgres backend.
Parameter stringification was extracted into a new
`Supavisor.HttpSql.Params` module — pure functions, easy to test
in isolation. The `ClientHandler` orchestration now calls
`Params.stringify_list/1` instead of the previous inline `stringify`
helpers.
Three helpers in `ClientHandler` were promoted from `defp` to
public-with-`@doc false` so the receive loop, mailbox flush, and
wire-bytes composition can be exercised directly:
* `recv_until_rfq/1`
* `flush_db_mailbox/0`
* `send_extended_query/3`
Test counts:
* `params_test.exs` — 15 tests (primitives, composites, list, round-trip-with-Wire.bind)
* `client_handler_test.exs` — 23 tests (callback, recv-loop, flush,
wire composition, 8 FakeDbHandler round-trips)
Two new test files cover the seams the previous test pass missed:
1. `test/supavisor/db_handler_checkout_api_test.exs` (6 tests)
The new `:caller_module` opt on `Supavisor.DbHandler.checkout/5`
was added without a direct assertion that the value actually
reaches the gen_statem call as expected. A small CaptureStub
gen_statem now intercepts `{:checkout, sock, caller, caller_module}`
and lets us assert exactly what gets sent for every opt shape:
* default opts → caller_module = Supavisor.ClientHandler
* legacy integer timeout → still defaults the module
* `caller_module: ...` opt → propagates verbatim
* `timeout:` only opt → still defaults the module
Plus error-wrapping: timeout on call → CheckoutTimeoutError,
dead process → DbHandlerExitedError.
2. `test/supavisor/http_sql/client_handler_xnode_test.exs` (3 tests)
The new `subscribe_remote/2` helper covers the case where
`Supavisor.start_dist/3` placed the pool on another node. We
can't easily spin up a second BEAM node in unit tests, but we
can verify the rescue/catch surface: unreachable node names,
bad node-arg shape, and remote raises all map to
`{:error, {:remote_workers_unreachable, _}}` without crashing
the Plug task.
This caught a real bug: `rescue e in [ArgumentError]` was too
narrow — `:erpc.call/4` actually raises `ErlangError` wrapping
`{:erpc, :noconnection}` / `{:erpc, :badarg}` / `{:exception,
_, _}`. Replaced with a wider `rescue e -> ...` so every erpc
failure path is captured.
Full happy-path multi-node coverage will land via
`Supavisor.Support.Cluster` peers in the existing
`:integration` test suite (same scaffolding used by
`cluster_pooling_test.exs`).
Six issues from the post-implementation review, all mechanical:
* **CRIT-1**: Removed stale references to the deleted
`Supavisor.HttpSql.PoolRegistry` from
`sql_controller_test.exs` setup. The integration test no longer
needs to evict an HTTP-specific pool — the request shares the
standard tenant pool.
* **CRIT-5**: Added a `%PgError{}` clause to
`SqlController.short_reason/1` so audit-log lines for database
errors carry the SQLSTATE (e.g. `"42601"`) instead of falling
through to `"unknown"`. The legacy `%Postgrex.Error{}` clause
was dead after the rewrite.
* **CRIT-6**: Added `db_user` to the `ctx` fixture in
`http_sql_facade_test.exs`. The new `ClientHandler` reads
`ctx.db_user` to build the pool id; the test would have crashed
on first integration run.
* **MED-4**: Deleted `WireDecoder.parse_prepare_response/1` and
its tests. The production path is single-round-trip
(Parse + Bind + Describe + Execute + Sync) — there is no
separate prepare step, so the function was unused public API.
* **Credo R1031**: Replaced explicit `try` blocks with implicit
form in `ClientHandler.pool_checkout/2` and
`ClientHandler.return_worker/2`.
* **`mix format`**: Auto-fixed line length in
`DbHandler.checkout/5` clause definition and two test-file
blank lines.
No behavioural changes; full unit suite (4 doctests + 128 tests)
still green.
…T-3, CRIT-4)
CRIT-3: `WireDecoder.ready_for_query?/1` used `:binary.match` to scan
for the literal 5-byte ReadyForQuery header anywhere in the buffer.
That false-positives on any `DataRow` whose payload contains the byte
sequence `<<?Z, 0, 0, 0, 5>>` — common in BYTEA hex output, JSON
escapes, or any binary text dumped as a column value. The receive
loop would then stop and `parse_execute_response/1` would return
`{:error, :incomplete}` with the user's actual rows trimmed off.
Replaced with a frame-aware check that calls `Server.decode/1` to
walk complete packet boundaries and only returns true when the LAST
fully-decoded packet has `tag: :ready_for_query`. Bytes that happen
to look like an RFQ header but are inside a DataRow are invisible
to the framed decoder.
CRIT-4: A new `safe_decode/1` wrapper catches any raise from
`Supavisor.Protocol.Server.decode/1` (e.g. binary-size mismatch on a
malformed length header) and surfaces it as `{:error, {:bad_packet,
_}}`. The HTTP request process can no longer crash from a corrupt
backend response — `ErrorMapper` maps both `:bad_packet` and
`:incomplete` to HTTP 502 ("bad_backend_packet" /
"incomplete_backend_packet" codes) so SREs can distinguish upstream
protocol corruption from query-level errors.
Tests:
* Regression for CRIT-3: a DataRow embedding `<<?Z, 0, 0, 0, 5>>`
is NOT detected as RFQ. The same buffer with a trailing real
RFQ packet IS detected, and `parse_execute_response/1`
round-trips the poison column value unchanged.
* Malformed length headers surface as `:incomplete`, never raise.
* ErrorMapper has explicit clauses for `{:bad_packet, _}` and
`:incomplete` → HTTP 502.
Bonus: removed `parse_prepare_response/1` and `walk_prepare/2` (dead
code — the production path is single-round-trip and never called
them; see commit `c3d3df4`).
… HIGH-3)
HIGH-1: Neither path called `Supavisor.Manager.unsubscribe/1`. The
subscription was cleaned up eventually via Manager's `:DOWN` handler
once the Plug process exited, but the slot kept counting against
`max_clients` until then. With a steady trickle of HTTP traffic the
subscriber count for a tenant never reached zero, so the Manager's
idle-shutdown timer never fired warm-pool teardown.
HIGH-3: Pool workers were never explicitly checked back in. On
success we relied on process-link death; on errors the same. That
left poolboy thinking the worker was busy until the Plug process
died and a DOWN propagated.
Refactored `run_query/4` and `run_batch/4` to:
1. After `subscribe/2` succeeds, wrap the rest of the work in
`try/after` so `unsubscribe/1` runs on every exit path.
2. After `db_checkout/2` succeeds, decide whether to return the
worker to poolboy based on the query result shape:
* `{:ok, _}` — RFQ received, DbHandler is back in `:idle`.
Safe to checkin.
* `{:error, %PgError{}}` — backend sent ErrorResponse + RFQ.
Worker is `:idle` even though query failed. Safe.
* any other error — worker is in unknown state (timeout,
dropped connection, malformed bytes). Drop it; the link
from DbHandler tears it down and poolboy spawns a fresh
one.
3. `flush_db_mailbox/0` always runs (was already in the success
path; now also in the `after` so error paths drain too).
Helpers added:
* `unsubscribe/1` — Best-effort wrapper around
`Supavisor.Manager.unsubscribe/1`. Catches `:exit, _` so a dead
manager doesn't propagate further.
* `maybe_return_worker/3` — dispatch table for the return logic
described above.
No new tests needed at this commit — the existing FakeDbHandler
round-trip tests already exercise the success and PgError paths;
explicit checkin/unsubscribe is asserted by inspection.
…ED-1)
HIGH-2: `recv_until_rfq/1` matched `{:EXIT, _pid, reason}` with a
wildcard pid, so any EXIT signal landing in the mailbox got labeled
`:db_handler_exit`. Cowboy sends `:shutdown` to in-flight Plug
processes during graceful node-stop; our code wrongly attributed
those to DbHandler crashes, polluting audit logs and metrics during
deploys.
The receive loop now takes an optional `db_pid` argument. EXITs
matching that pid → `:db_handler_exit` (real worker crash). EXITs
from any other source → `:shutdown_signal` (graceful stop, parent
supervisor cascade). `db_pid` is captured from `pool_checkout/2` and
threaded through `recv_until_rfq/2`, `simple_query/4`, and
`run_batch_queries/4` so every read in a single request shares the
same expected-worker tag.
The arity-1 form `recv_until_rfq/1` is preserved for tests that
drive synthetic messages without a real worker — `nil` db_pid means
"don't know which is ours", and any EXIT is conservatively treated
as a shutdown signal.
MED-1: `parse_command_complete/1` used `[tag, n]` pattern-match
which silently swallowed multi-word DDL verbs. `"CREATE TABLE"` came
out as `command = "CREATE"` with `num_rows = 0` (where 0 came from
`parse_int("TABLE")` falling through). The Neon driver passes the
`command` string straight to user code that may compare against the
full verb (`if (command === "CREATE TABLE") { ... }`).
New parser logic:
1. `INSERT 0 N` → `{"INSERT", N}` (legacy OID stripped).
2. `MERGE 0 0 0 N` (PG17+) → `{"MERGE", N}`.
3. Single token → `{tag, 0}`.
4. Multi-token: parse the LAST token as integer. If integer →
verb = `Enum.join(rest, " ")`, count = N. Otherwise → verb =
full joined string, count = 0.
Covers SELECT/UPDATE/DELETE/COPY/FETCH/MOVE (count-bearing),
CREATE/DROP/ALTER X (no count), and INSERT 0 N / MERGE 0 0 0 N.
Tests assert each shape explicitly.
Total unit suite: 4 doctests + 139 tests, 0 failures.
…GH-6)
Added a "What doesn't work (transaction-mode caveats)" section to
docs/connecting/http_sql.md so users understand the surface area of
sharing the tenant's transaction-mode pool with TCP clients:
* LISTEN/NOTIFY — no persistent session
* Server-side prepared statements — may hit a different worker,
and worse: leak across requests on the same worker
* Session-level SET — leaks to next user
* Temp tables — same leak
* Advisory locks held across requests — won't reach the same
session; use _xact_lock inside a batch
* COPY (in/out) — not streamed by /sql
* Multi-statement queries in a single `query` field — extended-
query protocol only runs the first statement; use `queries`
These are inherited transaction-mode caveats, not bugs introduced
by /sql — but they apply to the HTTP path too and aren't obvious to
users coming from a serverless mindset. Pointing them at session-
mode TCP for the affected cases avoids silent state corruption.
Also: trimmed a redundant trailing `with` clause in `do_run_query/4`
that credo's `Refactor.RedundantWithClauseResult` flagged. The third
match returned `{:ok, decoded}` for `{:ok, decoded}` — the outer
`<- WireDecoder.parse_execute_response/1` is enough.
Final state:
* mix format --check-formatted: clean
* mix credo --strict (http_sql modules): no issues
* mix compile --warnings-as-errors: clean
* unit suite: 4 doctests + 139 tests, 0 failures
…le} shape
Commit 166768b ("refactor(db_handler): take caller_module from
checkout opts") changed the gen_statem call from a 3-element
`{:checkout, sock, caller}` tuple to a 4-element form that carries
`caller_module` for `db_status/2` callback dispatch. The existing
`Supavisor.DbHandlerTest.MockDbHandler` and one direct
`handle_event/4` test still used the 3-element pattern and crashed
with `FunctionClauseError` under `mix test --include integration`.
Both updated to the 4-element shape. All 33 DbHandlerTest cases now
pass.
(Caught by running the full integration suite — these tests are
async-spawned via DbHandler, so the wrong call shape only surfaces
when the gen_statem itself is exercised, not during unit-level
checkout API testing.)
The HTTP /sql describe block's setup used to fetch `dev_tenant` —
which only exists in the dev seed db, not in the per-test
`create_instance/1` env that the rest of the external suite relies
on. Under `mix test --include integration` the setup raised
`KeyError :feature_flags not found in: nil` because
`get_tenant_by_external_id("dev_tenant")` returned `nil`.
Fixed: take ctx (which the module-level `setup ctx -> create_instance(...)`
populates with `:external_id`) and update THAT tenant's feature_flags
to enable `http_sql`. The global `:http_sql, :enabled` flag is still
flipped and restored. No need for an explicit on_exit revert of the
tenant's feature_flags — the tenant is destroyed by `create_instance`'s
own on_exit hook.
Test now passes against a real Postgres in 1.7s
(`neondatabase/serverless.js — HTTP /sql Node — drop-in /sql against
@neondatabase/serverless`).
When run under `mix test --include integration` (Supavisor app fully
booted), `:erpc.call(node(), Supavisor, :get_local_workers, [id])`
returns `{:error, %WorkerNotFoundError{}}` — the normal "no pool
exists" path, not a raise. The xnode test asserted
`{:error, {:remote_workers_unreachable, _}}` (the
rescue/catch-wrapped form used for unreachable nodes), so it failed
with `MatchError`.
That alone wouldn't matter — failing tests are normally isolated.
But ExUnit's recovery from a MatchError seems to disrupt enough
runtime state that subsequent cluster tests (`proxy_test`,
`circuit_breaker_cluster_test`, `promex_cluster_test`,
`syn_handler_test`, `stats_test`, `metrics_controller_test`) all
fail to bring up peer BEAM nodes via `:peer.call(...,
Application.ensure_all_started, :supavisor)` — every one of them
times out at 5 s.
Bisected: with this file removed from the suite, the same 55
cluster-using tests pass and the integration suite shrinks from 20
failures back to the 8-failure main baseline. With this file
present but assertion corrected, all 12 downstream cluster
failures also disappear.
Updated the test to assert what actually happens: a tagged
`%WorkerNotFoundError{}` propagated verbatim. The "remote
unreachable" code path is still covered by the other two tests in
the same file (non-existent node atom, bad-arg node binary), which
hit the rescue/catch branches via `:erpc`-side raises.
Result: `mix test --include integration` now reports 773 tests / 8
failures, matching main's baseline 1:1 (4× PromEx binary downloader
missing on this platform, 4× Bun/Deno runtimes not installed).
None of the failures are caused by HTTP /sql rewrite code.
|
@v0idpwn Could you please take a look? |
What kind of change does this PR introduce?
New HTTP endpoint.
What is the current behavior?
Supavisor speaks Postgres only over TCP. Serverless JS runtimes (Vercel, Cloudflare Workers) can't keep TCP pools between requests - every call pays a fresh SCRAM handshake. The WebSocket proxy at /v2 helps some clients but not the HTTP variant the JS ecosystem (@
neondatabase/serverless, @vercel/postgres,drizzle-orm/neon-http) actually uses.What is the new behavior?
POST /sqlwith the Neon wire format. Drop-in for@neondatabase/serverless:The controller is itself a Postgres client of Supavisor - it dials
127.0.0.1:proxy_port_transactionvia Postgrex, so every HTTP request reuses the existing ClientHandler -> pool -> DbHandler machinery. No changes to the TCP path.Highlights:
Supavisor.del_all_cache/1.Both gates are off by default:
HTTP_SQL_ENABLED=falseand per-tenantfeature_flags.http_sql.Additional context
Integration suite for the new endpoint is 101/101. A Node script at
test/integration/js/neon_http_sql/index.jsruns the real upstream@neondatabase/serverlessdriver against a live instance - release-gate smoke before each ship.TODO: cache_statement for repeat SQL, per-IP rate limit, JWT-auth path (rejected with 401 today).