Query Runner protocol
HTTP contract between the datahashi engine and Query Runner.
Updated
Query Runner runs parameterized SQL for the engine. It also streams rows for rollup builds and reads database schemas.
Query Runner never compiles queries. It never makes catalog or grant decisions. It runs only the SQL that its access policy returns, never the original request text.
sequenceDiagram
participant E as datahashi engine
participant R as Query Runner
E->>R: POST /v1/sql { request_id, tenant_id, sql, args, source }<br/>Authorization: Bearer runner-key
Note over R: 1. Check key hash and body size<br/>2. Check physical policy<br/>3. Execute on warehouse
R-->>E: HTTP 200<br/>JSON or NDJSON
Authentication
Every /v1/* request requires a Bearer token:
Authorization: Bearer <unhashed-runner-key>
The runner hashes the token with SHA-256. It matches the hash against keys[].sha256. A missing or unknown key is 401 with "error": "unauthorized".
/healthz needs no key.
1. Run SQL (POST /v1/sql)
Runs one parameterized statement against a named warehouse.
Request schema
{
"request_id": "req_01J",
"tenant_id": "org_01J",
"source": "main_warehouse",
"sql": "SELECT region, SUM(amount) AS revenue FROM public.orders WHERE status = $1 GROUP BY region ORDER BY region LIMIT $2",
"args": ["completed", 100],
"timeout_ms": 30000,
"max_rows": 10000,
"stream": false
}
| Parameter | Required | Description |
|---|---|---|
request_id |
Yes | Chosen by the engine. Echoed in the response and the audit line. |
tenant_id |
Yes | Written to the log only. The runner does not authorize tenants. |
sql |
Yes | One statement. The policy may rewrite it. Only the rewritten SQL runs. |
source |
When the runner has more than one warehouse | Must be a warehouse this key may use. |
args |
No | JSON null, number, string, or bool. Bound in order. PostgreSQL uses $1, $2. |
timeout_ms |
No | Capped by policy.statement_timeout. The smaller value wins. |
max_rows |
No | Capped by policy.max_rows. The smaller value wins. |
stream |
No | true answers as NDJSON. |
Unknown request fields are ignored.
Buffered response (stream: false)
{
"request_id": "req_01J",
"columns": [
{ "name": "region", "type": "text" },
{ "name": "revenue", "type": "numeric" }
],
"rows": [
["na", "45200.00"],
["emea", "38100.00"]
],
"row_count": 2,
"truncated": false,
"duration_ms": 14
}
columns is a list of {name, type} objects. There is no parallel types array. Timestamps are RFC3339 strings. Decimals are strings. Bytes are base64.
truncated is true when max_rows cut the cursor. Do not treat those rows as the full answer.
Streaming response (stream: true)
Rollup builds set stream to true. The runner writes rows as it reads them. Caps are max_stream_rows and stream_timeout. A request max_rows or timeout_ms can only tighten those caps.
The content type is application/x-ndjson. The first line is the header. Each middle line is one row. The last line is the trailer.
{"request_id":"req_01J","columns":[{"name":"region","type":"text"},{"name":"revenue","type":"numeric"}]}
["na","45200.00"]
["emea","38100.00"]
{"row_count":2,"truncated":false,"duration_ms":38}
A warehouse error before the header is a normal error response. After the header, the body ends with {"request_id","error","message"}. A body with no trailer and no error line is not a complete answer.
2. Read schema (POST /v1/schema)
Reads tables and columns. The key’s policy hides denied tables and columns. They are omitted, not listed and blocked.
Request schema
{
"request_id": "req_01J",
"tenant_id": "org_01J",
"source": "main_warehouse",
"schemas": ["public"]
}
schemas defaults to the warehouse’s configured list.
Response schema
{
"request_id": "req_01J",
"tables": [
{ "schema": "public", "name": "orders" }
],
"columns": [
{ "schema": "public", "table": "orders", "name": "id", "type": "bigint", "nullable": false },
{ "schema": "public", "table": "orders", "name": "amount", "type": "numeric", "nullable": false }
]
}
Tables do not nest their columns. The response has no primary_key field. An engine with no introspection query returns 501 and "error": "unsupported_backend".
3. Health and ping
| Method | Path | Key | Result |
|---|---|---|---|
| GET | /healthz |
No | Process is up. Does not touch the warehouse. |
| GET | /v1/readyz?source=<name> |
Yes | Warehouse ping. Same pause gate as /v1/sql. |
| POST | /v1/ping |
Yes | Same check as readyz. The body keeps the key out of the URL. |
GET /healthz returns {"status":"ok","enabled":true}. A paused runner still returns 200. There is no GET /v1/ping.
source is required when the runner has more than one warehouse. POST /v1/ping body fields are request_id, tenant_id, and optional source.
| Condition | Status | error |
|---|---|---|
| Warehouse answers | 200 | none. Body is {"status":"ok"}. |
| Paused | 503 | runner_paused |
| Bad key | 401 | unauthorized |
| Warehouse down | 503 | warehouse_unreachable |
Local admin listener
The admin port defaults to 127.0.0.1:9090. It has no key check. Bind it to loopback or a private network. The engine never calls these routes.
| Endpoint | Method | Function |
|---|---|---|
/admin/v1/pause |
POST | Set the kill switch in memory until resume, reload, or restart. |
/admin/v1/resume |
POST | Clear the in-memory pause. |
/admin/v1/status |
GET | {enabled, sources, keys, in_flight}. No secrets. |
/admin/v1/logs?n=100 |
GET | Last n audit records. |
/admin/v1/reload |
POST | Reload files and swap the snapshot. A bad file keeps the previous snapshot. Reload clears an in-memory pause. |
listen, admin_listen, TLS, and the log path need a process restart. Warehouses, keys, policies, and allow_cidrs apply on reload. Pools for warehouses that left the snapshot are dropped.
Error codes
Errors are one flat object. error is a string token, not an object.
{
"request_id": "req_01J",
"error": "policy_denied",
"message": "access to table 'public.employees' denied by runner policy"
}
policy_denied also adds grain, object, and rule.
| HTTP Status | error |
Cause |
|---|---|---|
400 Bad Request |
bad_request |
Missing request_id, tenant_id, or sql, or invalid JSON |
401 Unauthorized |
unauthorized |
Missing or unknown runner key |
403 Forbidden |
forbidden |
Caller IP is outside allow_cidrs. /healthz is exempt. |
403 Forbidden |
forbidden_statement |
Not a single SELECT or WITH |
403 Forbidden |
policy_denied |
A table, field, or value rule rejected the statement |
403 Forbidden |
unpoliced_backend_denied |
Non-PostgreSQL warehouse and the policy lacks allow_unpoliced_backends: true |
404 Not Found |
unknown_source |
source is not a warehouse, or it is not in the key’s sources. The two look the same. |
413 Payload Too Large |
payload_too_large |
Body exceeds max_request_bytes (default 1 MiB) |
429 Too Many Requests |
busy |
policy.concurrency is full |
500 Server Error |
internal |
Anything else. The message never contains the DSN. |
501 Not Implemented |
unsupported_backend |
/v1/schema on an engine with no introspection. /v1/sql is unaffected. |
503 Service Unavailable |
runner_paused |
Kill switch is on |
503 Service Unavailable |
warehouse_unreachable |
Driver open or ping failed, or a non-timeout driver error. A SQL syntax error looks the same. |
504 Gateway Timeout |
timeout |
Statement timeout or context deadline |
The engine remaps some of these for its own callers. policy_denied becomes 403 runner_policy_denied. runner_paused and warehouse_unreachable become 503 runner_unavailable with Retry-After: 30. unauthorized, unknown_source, and forbidden become 502 runner_misconfigured.
Next: REST API · Environment variables.