Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@

### Added

- `sqlflow serve` answers requests from a pool of sessions rather than one
connection. `serve.pool.size` sets how many run at once, four by default,
measured to peak at 72 MiB resident on Linux against a 256 MB box. Every
session is pinned to UTC, and the config's `commands` run once, on a
connection of their own, because `ATTACH` is database-wide while
`SET TimeZone` is not. A response carries `queued_ms` beside `elapsed_ms`,
so a busy pool is no longer reported as a slow query. `/healthz` answers
`busy` rather than `unavailable` when no session is free, and answers
`HEAD` for monitors. With `serve.metrics.enabled`, `GET /metrics` serves
six instruments, including the session wait that sizes the pool. A request
that gives up now stops reading at the next batch and releases its reader,
which is ADBC's documented equivalent of cancelling.

- `sqlflow serve`: an `integer` param may declare `min` and `max`. A request
outside them is `400 invalid_param` naming the bounds, and `/v1/datasets`
lists them.
Expand Down
44 changes: 35 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,14 @@ serve:
limits:
max_rows: 10000
timeout_seconds: 10
pool:
# Requests answered at once. Each session is a backend session; a
# concurrent query costs a few MiB. Omit for the default, 4.
size: 4
metrics:
# Serve GET /metrics on this listener, without a token. Off by default:
# the listener is public and the labels name every dataset.
enabled: false
datasets:
- name: posts_by_lang
description: Posts per bucket per language.
Expand Down Expand Up @@ -325,7 +333,8 @@ Routes, all `GET`:

| Route | Auth | Returns |
|---|---|---|
| `/healthz` | none | `200` when the connection answers `SELECT 1`, `503` otherwise |
| `/healthz` | none | `200` `{"status":"ok"}` when a session answers `SELECT 1`; `503` `{"status":"busy"}` when none is free, `{"status":"unavailable"}` when the query fails. `HEAD` is answered too, for monitors |
| `/metrics` | none | Prometheus text, only when `serve.metrics.enabled` is set |
| `/v1/datasets` | bearer | Every dataset: its params, and its SQL as written |
| `/v1/datasets/{name}` | bearer | Rows |

Expand All @@ -335,12 +344,19 @@ $ curl -H 'Authorization: Bearer <token>' \
{"dataset":"posts_by_lang","grain":"1h",
"columns":[{"name":"bucket","type":"TIMESTAMP WITH TIME ZONE"},...],
"rows":[{"bucket":"2026-09-10T00:00:00Z","lang":"en","posts":102340}],
"row_count":1,"truncated":false,"elapsed_ms":41}
"row_count":1,"truncated":false,"queued_ms":0,"elapsed_ms":41}
```

A zoned timestamp is UTC. A decimal is a string of its exact digits. `NaN`
and infinities are strings. `truncated: true` means `max_rows` cut the result.

`elapsed_ms` is the query. `queued_ms` is how long the request waited for a
session, which is what rises when the pool is too small for the load.

Every route is `GET`, and `/healthz` also answers `HEAD`. A `HEAD` of a
dataset would run its query, borrow a session and discard the rows, so it is
refused with `405`.

A token is an identifier, not a secret: a browser page ships it in plain
sight. It names the caller in the request log, and deleting it revokes the
caller.
Expand Down Expand Up @@ -371,13 +387,23 @@ What to know before you deploy it:
tables in Postgres and generates the datasets that read them. A Postgres
view with the `GROUP BY` also pushes the filter in, but re-aggregates the
range on every request.
- **Bound Postgres connections.** One scan opens up to `pg_connection_limit`
connections, 64 by default. Set it low for a small database, as a command.
- **A timeout does not stop the query.** DuckDB cannot be cancelled through
its Go driver. At the deadline the caller gets `504`, and the query runs to
completion. `max_rows` does stop it early.
- **One connection serves every request.** Requests run one at a time. A slow
query makes the ones behind it wait, and `/healthz` waits with them.
- **Bound Postgres connections.** An attachment opens up to
`pg_connection_limit` connections, 64 by default. Set it low for a small
database, as a command. The pool does not multiply it: with eight sessions
scanning at once and a limit of four, the measured peak was four, so the
connections are shared across sessions rather than opened per session.
- **A timeout stops reading, not always the query.** At the deadline the
caller gets `504`, and the reader stops at the next batch and is released,
which is ADBC's equivalent of cancelling. An operator that runs long before
yielding a batch still runs to the end, holding its session. Bound the part
that is usually slow in the backend instead: a libpq connection string takes
`options='-c statement_timeout=30000'`, so an attached Postgres enforces its
own ceiling.
- **A pool serves requests.** `serve.pool.size` sessions answer at once, four
by default. A request waits for a free session, and that wait counts toward
the dataset's timeout, so an exhausted pool answers `504 query_timeout`.
`queued_ms` in the response and `sqlflow_serve_session_wait_seconds` in the
metrics say whether the pool is the limit.

### `sqlflow rollup`

Expand Down
2 changes: 1 addition & 1 deletion docs/coverage/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ features:

- id: cli.serve
description: Serves a config's named SQL datasets over HTTP, with bearer tokens, typed params, grains and limits.
requires: [unit, release]
requires: [unit, integration, release]

- id: cli.rollup
description: Generates rollup tables, the triggers that keep them current, and the serve datasets that read them from one declaration, and checks the generated files have not drifted.
Expand Down
2 changes: 1 addition & 1 deletion docs/coverage/matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ added, and this page changes only when a status does.
| `observability.debug_api` | Serves ad-hoc SQL against the live DuckDB connection. | ✅ | — | — |
| `cli.invocation` | Resolves the config path and message limits from either flag form. | ✅ | — | — |
| `cli.dev_invoke` | Runs a pipeline against a fixture file, without a source. | ✅ | — | ✅ |
| `cli.serve` | Serves a config's named SQL datasets over HTTP, with bearer tokens, typed params, grains and limits. | ✅ | | ✅ |
| `cli.serve` | Serves a config's named SQL datasets over HTTP, with bearer tokens, typed params, grains and limits. | ✅ | | ✅ |
| `cli.rollup` | Generates rollup tables, the triggers that keep them current, and the serve datasets that read them from one declaration, and checks the generated files have not drifted. | ✅ | ✅ | — |
| `cli.version` | The shipped binary reports the version it was built from. | — | — | ✅ |
| `tooling.conformance` | The harness proves the declared invariants for any integration. | ✅ | — | — |
Expand Down
2 changes: 1 addition & 1 deletion docs/coverage/status/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
cli.dev_invoke: {unit: covered, integration: not_required, release: covered}
cli.invocation: {unit: covered, integration: not_required, release: not_required}
cli.rollup: {unit: covered, integration: covered, release: not_required}
cli.serve: {unit: covered, integration: not_required, release: covered}
cli.serve: {unit: covered, integration: covered, release: covered}
cli.version: {unit: not_required, integration: not_required, release: covered}
config.templating: {unit: covered, integration: not_required, release: covered}
config.validation: {unit: covered, integration: not_required, release: covered}
Expand Down
Loading
Loading