Skip to content

sqlflow serve: named SQL over HTTP - #278

Merged
turbolytics merged 15 commits into
mainfrom
feat/serve
Sep 13, 2026
Merged

sqlflow serve: named SQL over HTTP#278
turbolytics merged 15 commits into
mainfrom
feat/serve

Conversation

@turbolytics

@turbolytics turbolytics commented Sep 13, 2026

Copy link
Copy Markdown
Owner

What

sqlflow serve: named SQL over HTTP, from the same config machinery run uses. A serve config declares datasets, each a fixed statement with typed parameters, or one statement per grain. serve attaches the data through commands:, prepares every statement at startup, and answers each request by binding its parameters. Nothing in a request becomes SQL text.

Spec: docs/superpowers/specs/2026-09-12-serve-design.md. This branch carries the spec commits from #275, so this PR supersedes it.

SQLFLOW_SERVE_TOKEN=local-dev-token sqlflow serve dev/config/serve/local.table.yml
curl -H 'Authorization: Bearer local-dev-token' \
  'localhost:8080/v1/datasets/city_events?grain=1d&city=Baltimore'

How it is built

  • internal/sqlparams numbers $name placeholders. DuckDB binds ADBC parameters by position and ignores field names, so the server numbers them itself and compares its count with DuckDB's at startup. An E'…' string with an escaped quote fools the scanner, and the test shows the count check refusing to start.
  • internal/config/serve.go holds the types, a strict loader, and the rules the schema cannot state. serve.json is generated from the types beside config.json, and validate picks the schema by top-level key. A pipeline file validates exactly as before.
  • internal/serve encodes rows, runs statements, and serves the routes. Each request plans a fresh statement, so a table that changes under the process is read as it is now.
  • internal/cli/serve is the command.

Behaviour worth reviewing

  • Timeouts. The Go driver manager cannot cancel a DuckDB query. At the deadline the caller gets 504, and the query keeps the connection's lock until it finishes. The README says so.
  • Values. Arrow's JSON rendering printed a New York session's UTC midnight as 20:00-04:00 and a HUGEINT as 1.7e+38. The encoder renders a zoned timestamp in UTC and a decimal as exact digits. The tests fail with either case removed.
  • Tokens are identifiers compared with subtle.ConstantTimeCompare against every token. The log carries a token's name, never its value.
  • rate_limit is parsed and refused with user.config.serve_reserved, so a config written now keeps its shape.

Evidence

  • go test -short -race ./... passes.
  • uv run --locked pytest tests/tooling -q passes, and make coverage-check is clean: cli.serve covered at unit and release.
  • The release suite passes against an image built from this branch, including test_cli_serve_answers_requests_from_the_image.
  • TestCliServe_DoesNotLeakNativeMemory grows about 1 MiB over 500k rows. Retaining each record batch in the encoder fails it.
  • Built and run by hand against local.table.yml: health, a grain with a param, a dataset without grains, a 401, a clean exit on SIGTERM. sqlflow validate dev/config/serve/bluesky.postgres.yml passes with warnings and no variables set.
  • make soak (10 min, run, as a regression check on the shared loader): PASS, flat over 105,286,753 messages at +0.022 B/msg against a 1.0 threshold. serve has no sustained-load soak yet; its memory gate is the leak test.
  • Postgres drops: a probe killed serve's Postgres connections mid-run and DuckDB reconnected on the next request. With Postgres unreachable, the 500 body used to carry the connection string with its password. 00432fe fixes that and redacts the log.

Not in this PR

Rate limiting, caching, a connection pool, per-token allowlists, pagination, TLS, and --metrics. Each is a follow-up in the spec.

The first consumer is turbolytics/sql-flow-bluesky-demo#2, which waits on a v1.3.0 release.

A pipeline ends at a sink and the rows need an API. This designs a serve
command: a separate config with commands and datasets, bearer tokens as
identities, typed params bound through prepared statements, a row cap and
a timeout, and a reserved rate-limit policy that is refused until enforced.

Verified on DuckDB 1.5.2 that the postgres extension pushes filters and
projections down but not GROUP BY, and that a filter against a Postgres
view pushes into the view. The example config is shaped by that.
The spec left binding and cancellation to the plan and asserted that $from
fails to parse. A probe against DuckDB 1.5.2 and arrow-adbc 1.6.0 settled
all three, and two of the answers were not the ones the spec planned for:

- DuckDB binds by position. The parameter schema names fields 0 and 1, and
  a bound record's field names are ignored. The server now rewrites $name
  to $N itself and checks DuckDB's parameter count against its own.
- The Go driver manager ignores the context and exposes no cancel. A
  timeout returns 504 while the query runs on and holds the lock.
- $from prepares fine. The CLI's EXECUTE call syntax rejected it. The
  reserved-word rule is gone.

Also from the probe: SetSqlQuery binds eagerly, so a missing view fails at
startup; Arrow renders HUGEINT through a float, so decimals encode as exact
strings; results stream, so the row cap stops the query. Serve examples
move out of dev/config/examples, where two sweeps decode every file as a
pipeline. --metrics is dropped until serve records an instrument.
DuckDB binds ADBC parameters by position. Its parameter schema names
them 0, 1 with type null, and a bound record's field names are ignored,
so a statement using $since before $until binds whatever arrives first
to $since. A Go probe against DuckDB 1.5.2 and arrow-adbc 1.6.0 showed
it. sqlparams.Rewrite numbers each $name in order of first appearance,
skipping strings, quoted identifiers, comments and dollar quotes, and
rejects a positional $1, which DuckDB refuses to mix with names.

user.config.serve_reserved refuses a rate_limit this version parses and
does not enforce. user.config.serve_dataset names a broken dataset rule.
Both exit 10, and codes.golden records them.

cli.serve joins features.yml, requiring unit and release coverage.

If the scanner misnumbers a placeholder, a request binds a value to the
wrong column and answers 200 with wrong rows.
ServeConf is its own file type: commands plus a serve block. It decodes
strictly through the same decodeStrict LoadRendered now uses, so a typo
in a serve file is an error rather than a dropped setting.

Check returns every rule violation with its YAML path: unique names,
sql xor grains, every $name declared and every param used by every
statement, unique non-empty tokens, exact lowercase origins, and a
non-zero rate_limit refused. Paths are copied with slices.Concat. Built
with append, two faults in one param shared a backing array and the
second overwrote the first one's path; the test for that fails on the
append version.

If a grain ignoring a param were accepted, a request filtering on that
param would get unfiltered rows.
internal/schema generates serve.json from ServeConf beside config.json,
and a golden test holds it current. validate checks a file with a
top-level serve key and no pipeline key against serve.json, then runs
the serve rules, each at its line. A file with both keys is a pipeline
exactly as before, so serve reports as unknown. A rule the schema
already reported at the same key is not repeated.

An empty token rendered from an unset variable is demoted to a warning
like any other unsupplied value, so CI without secrets passes.

config example --serve renders the serve skeleton. The renderer now
prints a map's value keys under a <name> placeholder; without that,
grains: printed empty. config_example.golden is unchanged.

dev/config/serve holds a local-table example and the bluesky Postgres
example. They live outside dev/config/examples because two sweeps
decode every file there as a pipeline.

If validate chose the pipeline schema for a serve file, every serve
file would fail with pipeline is required.
readRows encodes each value while its record is current and stops at
the first row past max_rows, which stops DuckDB's stream. A zoned
timestamp renders in UTC whatever the session zone, a decimal as its
exact digits, and NaN or infinity as strings. Arrow's own rendering
printed a New York session's midnight UTC as 20:00-04:00 and a HUGEINT
as 1.7e+38; the tests fail when those cases are removed.

prepare numbers a statement, binds it once at startup so a missing
table fails there, and compares DuckDB's parameter count to the
scanner's. An E'' string with an escaped quote makes them differ, and
the test shows prepare refusing it. Each request plans a fresh statement
and binds a typed record, so an absent param is a typed null.

The Go driver manager cannot cancel a query. executor bounds the
caller's wait: at the deadline run returns, and the query keeps the lock
until it finishes. The timeout test uses a 1 s cross join, because
range(200000000) took 320 ms here and would race a 100 ms deadline on a
larger runner.

If a query goroutine wrote the response, a timed-out request would race
the handler's own 504.
…shape

New checks the rules and prepares every statement, so a dataset that
cannot answer stops the process at start. Handler serves /healthz,
/v1/datasets and /v1/datasets/{name}, all GET, with CORS, the method
check and auth ahead of anything that waits on the connection's lock.

A token is compared with subtle.ConstantTimeCompare against every
configured token, with no early return. Params parse against their
declared type in sorted key order; an unknown name is 400 rather than
a silent NULL. Every refusal is {"error": {"code", "message"}}, and
the tests assert each code's status and that its message names the
cause. One log line per request carries the token's name, never its
value.

Serve drains in-flight requests for 5 s on shutdown. The drain test
cancels during a 1 s query and gets its 200.

If identify compared with ==, response time would leak a token a byte
at a time.
serve loads and checks the config before opening DuckDB, runs its
commands through core.InitCommands, prepares every statement, listens on
http.addr, and serves until SIGINT or SIGTERM. The server closes before
the connection, so a query still holding the lock finishes first. A
failed ATTACH stays uncoded and exits 1, as in run, so a supervisor
retries a database that is not up yet.

The tests serve a config on port 0 and answer /healthz and a dataset,
refuse a rate_limit with exit 10, and refuse an empty token that
validate only warns about. Built and run by hand against
dev/config/serve/local.table.yml: health, a grain with a param, a
dataset without grains, a 401 without a token, and a clean exit on
SIGTERM.

If serve demoted an empty token as validate does, it would start and
refuse every request.
Every file in dev/config/serve validates with no variables set, loads
with the variables a deploy supplies, and builds a real server when its
commands can run offline. local.table.yml builds; bluesky.postgres.yml
skips at INSTALL postgres under enable_external_access=false, as the
Postgres pipeline examples do.

The leak test sends 500 requests of 1000 rows through the handler and
fails above 8 MiB of resident growth. It grows about 1 MiB as written.
Retaining each record batch in readRows fails it. Retaining the reader
does not, because an exhausted stream holds almost nothing.

If the encoder kept a record alive past its reader, every response would
pin DuckDB's buffers for the life of the process.
The README documents the config, the routes, the response and error
shapes, and the five things to know before deploying: attach READ_ONLY,
aggregate in a backend view because DuckDB does not push GROUP BY, bound
pg_connection_limit, a timeout leaves the query running, and one
connection serves every request.

The release test starts serve from the image against
dev/config/serve/local.table.yml and checks /healthz, the listing, a
grain with a param, and a 401. That is the proof the command exists in
the artifact users pull, which no unit test gives.

If the command were not registered in the image's binary, every serve
deploy would fail at start with an unknown command.
Five departures, each found while building it: origins must be
lowercase because the comparison is exact; a 401 carries
WWW-Authenticate and the Bearer scheme is case-insensitive; every
response says no-store, and a 503 health body is {"status":"unavailable"};
the log line carries path, and a caller that hangs up logs 499; and the
timeout test uses a cross join, because range(200000000) finished in
320 ms and would race a 100 ms deadline on a larger runner.

If the spec kept the old test query, a reader re-deriving the test from
it would write a flaky one.
make coverage-matrix ran every suite against an image built from this
branch: go test -short -race, the integration pass, and all 18 release
tests. No test failed. status/features.yml gains one line, cli.serve
covered at unit and release, and matrix.md is regenerated from it.

If the release test did not run, cli.serve would report release:
missing and the merge gate would fail.
query_failed returned DuckDB's error text, and DuckDB's Postgres
extension puts the whole connection string in a connection error. A
probe proxied serve's Postgres connection, killed the proxy mid-run, and
got back, with the demo token anyone can read from the page:

  Unable to connect to Postgres at "postgresql://postgres:postgres@..."

The body now names the dataset and grain only. The log keeps DuckDB's
error, passed through Redact, which replaces the password in a URI's
userinfo or a libpq password= keyword with ***. A failed ATTACH at start
is redacted too, since it goes to the same log.

The new test sends a cast error that echoes a URI with a password. It
fails against the old body, and the re-run probe against the rebuilt
binary returns 'dataset probe failed; the server log has the database's
error' with the logged URI reading postgres:***@.

run logs a failed ATTACH unredacted today. That is a follow-up.

If this regressed, one Postgres outage would publish the database
password to every visitor of the demo page.
#275 squash-merged this branch's first two commits, so both sides added
docs/superpowers/specs/2026-09-12-serve-design.md. main's copy is
byte-identical to ac97dbc. This branch's copy is that plus 40ec437 and
00432fe, which record what implementation changed and the 500 body that
no longer carries DuckDB's error, so the conflict resolves to this
branch's copy.

#277's README, example and manager changes merged cleanly. The cli,
config, schema, validate, managers and serve packages pass under -race
after the merge.
…ates

CHANGELOG.md: main now keeps an Unreleased section. serve's entries join
its Added list, and serve's limits sit under Known limits, rather than
under a v1.3.0 heading of their own. The version is named when it is
tagged.

internal/errs/errs_test.go: both sides appended a test at the end of the
file. Both are kept.

internal/validate/validate.go merged without a conflict and with a bug:
#273's checkDrainDeadline ran on every file, so a serve file reported
pipeline.drain_deadline: pass for a pipeline it does not have. A check
that could not apply must not read as a pass. Each file now gets its own
checks: serve rules for a serve file, the drain deadline for a pipeline.
TestValidateServe_AValidServeFilePasses now asserts no pipeline check
reports on a serve file; it failed on the merged code and passes now.

docs/coverage/matrix.md merged as text into a page that no longer matched
its status files, and test_the_committed_page_is_current failed. make
coverage-page regenerated it: 39 features, cli.serve covered at unit and
release, 0 gaps.

go vet ./..., go test -short -race ./... and pytest tests/tooling pass on
the merge.
@turbolytics
turbolytics merged commit 5e16dec into main Sep 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant