The whole streaming engine is three JSON POSTs. /streams/v1 sits inside the versioned contract
alongside /api/v1 (see compatibility), so these routes are a public
protocol rather than an SDK’s private wire: a language with no Queen streams SDK can run a complete
streaming worker by registering a query, reading its state, and committing cycles.
| Route | Access level | What it does |
|---|---|---|
POST /streams/v1/queries |
read-write | Registers or re-registers a query by name, returns its query_id |
POST /streams/v1/state/get |
read-only | Reads the state rows for one query and one partition |
POST /streams/v1/cycle |
read-write | Commits state writes, sink pushes and the source ack in one transaction |
None of the three is tenant-scoped. A query name is global to the database, and the cycle
resolves its sink queues on the default tenant. See the model for what a worker
does between these calls.
POST /streams/v1/queries
Registration is idempotent on name, and it is where a changed operator chain is caught.
| Field | Type | Required | Meaning |
|---|---|---|---|
name |
string | yes | The query’s global identity. This is the SDK’s queryId |
source_queue |
string | yes | The queue the worker pops from |
sink_queue |
string | no | The queue closed windows are pushed to. An empty string is stored as null |
config_hash |
string | yes | A hash the client computes over the operator chain. The handler rejects a missing or empty value with 400 before the procedure runs |
reset |
boolean | no, default false |
Delete every state row for this query before rewriting the registration |
Three outcomes:
- The name is new. The row is inserted and the response carries
fresh: true. - The name exists and
config_hashmatches.source_queueandsink_queueare updated to the supplied values and the accumulators are kept.freshisfalse. - The name exists and
config_hashdiffers. Withresetabsent orfalsethis is HTTP 409, withsuccess: falseand an error naming the query. Withreset: truethe broker deletes everyqueen_streams.staterow for the query, stores the new hash, and answers 200 withreset: true.
A 200 body carries the identity every later call uses. query_id is that value; the name never
appears on the wire again.
{
"success": true,
"query_id": "0193f0c4-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"name": "orders.per_minute",
"config_hash": "9f2c…",
"fresh": false,
"reset": false
}The 409 is the only 409 the broker emits (see status codes). It exists because old accumulators and a new fold shape do not mix: a chain that used to sum and now averages would read the old value as if it meant the new thing.
POST /streams/v1/state/get
Read-only, and the only way to see a window before it closes. Closed windows are ordinary messages on the sink queue and need nothing from this route.
| Field | Type | Required | Meaning |
|---|---|---|---|
query_id |
uuid string | yes | From registration |
partition_id |
uuid string | yes | The source partition whose state shard you want |
keys |
string array | no | Exact keys. Absent, null or [] all mean no key filter |
key_prefix |
string | no | Returns keys beginning with this string. An empty string is treated as absent |
ripe_at_or_before |
number | no | Epoch milliseconds. Returns only rows whose value.windowEnd is a JSON number at or before it |
The filters are ANDed in that order, so keys and key_prefix together mean both must hold. The
response is {"success": true, "rows": [{"key": "…", "value": {…}, "updated_at": "…"}]}, ordered by
key. A key that does not exist is absent from rows, not present with a null value, so an
empty rows and an unknown query_id look identical from here.
ripe_at_or_before is what an idle-flush timer uses: ask for the windows that are already due on a
partition that has stopped receiving, emit them, and delete their rows in the same cycle. A state
row whose value has no numeric windowEnd can never match that filter.
Missing query_id or partition_id produces 400, because the procedure reports success: false for it and the handler maps that to 400. An error field in the result is 500.
POST /streams/v1/cycle
One call, one PostgreSQL transaction: the state writes, the sink pushes and the source ack land together or not at all. This is the route the exactly-once argument rests on.
| Field | Type | Required | Meaning |
|---|---|---|---|
query_id |
uuid string | yes | 400 if absent or empty |
partition_id |
uuid string | yes | The source partition. 400 if absent or empty |
consumer_group |
string | no | Defaults to __QUEUE_MODE__ when absent or empty. The SDKs send streams.<queryId> |
state_ops |
array | no, default [] |
Applied in order against queen_streams.state, scoped to this query and partition |
push_items |
array | no, default [] |
Sink messages. The broker packs them into segments before the procedure runs |
ack |
object or null |
no | The source ack. null is an idle-flush cycle: state ops and sink pushes only, no cursor movement |
release_lease |
boolean | no, default true |
false retains the source lease on the un-acked tail |
The two arrays and the ack have fixed element shapes. A state key is an opaque string to this
route; the SDKs compose it from the operator tag, the window key and the user key joined by
U+001F, which is what makes a key_prefix read return exactly one operator’s windows.
{
"state_ops": [
{"type": "upsert", "key": "tumb:60\u001f2026-08-17T10:01:00.000Z\u001facme", "value": {"acc": 41}},
{"type": "delete", "key": "tumb:60\u001f2026-08-17T10:00:00.000Z\u001facme"}
],
"push_items": [
{"queue": "orders.per_minute", "partition": "Default", "payload": {"count": 41}}
],
"ack": {"transactionId": "…", "leaseId": "…", "status": "completed", "count": 50}
}Any type other than upsert or delete raises inside the element and fails the whole cycle,
which is the intended outcome: a typo must not commit a partial fold.
In a push_items element, partition defaults to Default and payload may also be sent as
data. An item with an empty queue is skipped rather than rejected. You may supply messageId
and transactionId, and the broker stamps a UUIDv7 message id and reuses it as the transaction id
when you do not.
leaseId is the worker id the pop returned, and it is the exactly-once guard: the procedure locks
the cursor row and raises if the lease is absent, held by another worker, or expired, which rolls
the sink pushes back with it. status counts as success for completed, success, acked, ok
and for an absent field; anything else, including a misspelling, is a nack and redelivers the
batch.
What the ack does to the cursor
status |
release_lease |
Effect |
|---|---|---|
| ok | true |
committed jumps to the recorded batch_end, the lease and the retry state are cleared, total_consumed grows by count. Identical to an ordinary ack of the whole leased batch |
| ok | false |
committed advances by exactly count frames, walked forward over the real queen.log_segments ranges so offsets removed by retention are skipped, and the lease is retained. This is the gate partial ack |
| not ok | either | The lease is released and the cursor is untouched, so the whole batch redelivers. The reported count is forced to 0 |
ack: null |
either | The lease block is skipped entirely |
The response
A completed procedure call is always HTTP 200, whether the element succeeded or not, so a client must read the body rather than the status line. A 500 means the call itself failed, and retrying it is safe because the whole call is one transaction.
{
"success": true,
"query_id": "0193f0c4-...",
"partition_id": "0193f0c5-...",
"queueName": "orders",
"state_ops_applied": 3,
"push_results": [
{"queue": "orders.per_minute", "partition": "Default", "status": "queued", "baseOffset": 4711}
],
"ack_result": {"success": true, "count": 50, "lease_released": true, "dlq": false}
}On failure the same envelope comes back with "success": false and an error string carrying the
PostgreSQL message, and nothing committed for that element.
What the broker adds on this route
Three things happen in the handler rather than in the procedure, and a client implementing the protocol by hand inherits all of them:
- Packing.
push_itemsare grouped by queue and partition, framed, zstd-compressed and hashed broker-side, then pushed throughqueen.log_push_one_v1, the same allocatorPOST /api/v1/pushuses. Stream output is not a second class of message. - Encryption. Sink payloads for a queue with
encryptionEnabledare enveloped before packing, and a cipher failure warns and stores plaintext rather than failing the cycle. See payload encryption. - Discoverability and metrics. Because the procedure commits internally, neither the push nor the ack fast path runs afterwards, so the handler does their bookkeeping: it marks the sink partitions in the hot list (or wakes parked pops directly when the hot list is off) and attributes the cycle’s pushes and acks to the per-queue counters itself. Without that step a sink emit stays invisible to consumers until the periodic reseed sweep, and the charts read zero while the queue visibly fills.
Streaming queries
What a worker does between these calls: windows, event time, gating, and why the cycle is exactly-once.
Status codes
Every code the broker returns, including the 409 that only registration emits.
Schema and procedures
The queen_streams tables and the stored procedures these three routes call.