---
title: stream
---
available in builds compiled with the `indexer_stream` feature. `/stream` is hydrant's ordered websocket event stream. each subscriber receives a full copy of the stream.
`/stream` is hydrant's ordered websocket event stream, and every subscriber gets a full copy of it. it's only in builds with the `indexer_stream` feature.
`/stream` is not wire- or protocol-compatible with [`tap`](../concepts/vs-tap.md). tap uses a sharded work queue, server-managed acknowledgements, and redelivery; hydrant uses broadcast delivery and client-managed cursors.
`/stream` isn't wire or protocol compatible with [`tap`](../concepts/vs-tap.md). tap hands events out through a sharded work queue, with acks the server tracks and redelivery. hydrant broadcasts every event to everyone and leaves the cursor to the client.
## GET /stream
subscribe to the ordered event stream.
### query parameters
| param | type | description |
| :--- | :--- | :--- |
| `cursor` | integer | inclusive `u64` event ID from which to replay durable record events. |
| `stream` | string | the stream `cursor` came from, see [`/stream/head`](#get-streamhead). when it isn't this one, hydrant sends a `StreamMismatch` error frame and closes the connection. |
| `cursor` | integer | `u64` event id to replay durable record events from, inclusive. |
| `stream` | string | the stream `cursor` came from (see [`/stream/head`](#get-streamhead)). if it isn't this one, hydrant sends a `StreamMismatch` error frame and closes the connection. |
if `cursor` is omitted, the connection starts at the current stream head and receives only new events. if it is present, hydrant replays durable record events whose IDs are greater than or equal to the cursor, up to the head observed when the connection starts, then switches to live delivery.
without `cursor`, the connection starts at the current head and only gets new events. with it, hydrant first replays the durable record events from the cursor up to the head as it was when you connected, then switches to live events.
`cursor=0` replays all retained record events. IDs are monotonic but may have gaps: live-only identity and account events consume IDs without being persisted, and events that cannot be inflated are skipped.
`cursor=0` replays every record event still stored. ids always go up but can skip numbers, because identity and account events use ids without being stored, and events hydrant can't render are skipped.
### event envelope
events are JSON text frames with this envelope:
events are json text frames shaped like this:
```json
{
"id": 42,
```
| field | type | description |
| :--- | :--- | :--- |
| `id` | integer | `u64` stream event ID. |
| `id` | integer | `u64` stream event id. |
| `type` | string | `record`, `identity`, or `account`. |
| `record` | object | present when `type` is `record`. |
| `identity` | object | present when `type` is `identity`. |
| `account` | object | present when `type` is `account`. |
only the payload matching `type` is present.
### record
record events are durable and available through cursor replay.
record events are durable, and a cursor replay can get them.
```json
{
"id": 42,
```
| field | type | description |
| :--- | :--- | :--- |
| `live` | boolean | `true` for live ingestion; `false` for backfill. |
| `did` | string | repository DID. |
| `rev` | string | repository revision TID. |
| `collection` | string | collection NSID. |
| `live` | boolean | `true` for live events, `false` for backfill. |
| `did` | string | repository did. |
| `rev` | string | repository revision, a tid. |
| `collection` | string | collection nsid. |
| `rkey` | string | record key. |
| `action` | string | `create`, `update`, or `delete`. |
| `record` | object | decoded DAG-CBOR record, its keys in the order the record's CBOR has them (shorter first), not sorted; omitted when no record content is stored. |
| `cid` | string | CID of `record`; omitted when no record content is stored. |
| `record` | object | the decoded dag-cbor record, its keys in the record's cbor order (shorter first), not alphabetical. left out when there's no stored body. |
| `cid` | string | cid of `record`. left out when there's no stored body. |
`record` and `cid` are omitted for deletes and when hydrant is configured not to store record content, including `HYDRANT_ONLY_INDEX_LINKS=true`.
`record` and `cid` are left out for deletes, and when hydrant doesn't store record bodies, like with `HYDRANT_ONLY_INDEX_LINKS=true`.
a redaction stops a body from being served once it completes, except in events already queued for a reader at that moment.
once a redaction finishes, the body isn't served anymore, except in events that were already queued for a reader by then.
### identity
identity events are live-only and are not available through cursor replay.
identity events are live only, and a cursor replay doesn't get them.
```json
{
"id": 43,
```
| field | type | description |
| :--- | :--- | :--- |
| `did` | string | repository DID. |
| `handle` | string | current handle; omitted when unavailable. |
| `did` | string | repository did. |
| `handle` | string | current handle, left out when hydrant doesn't have one. |
### account
account events are live-only and are not available through cursor replay.
account events are live only, and a cursor replay doesn't get them.
```json
{
"id": 44,
```
| field | type | description |
| :--- | :--- | :--- |
| `did` | string | repository DID. |
| `did` | string | repository did. |
| `active` | boolean | whether the repository is active. |
| `status` | string | account status; omitted when unavailable. |
| `status` | string | account status, left out when hydrant doesn't have one. |
### websocket behavior
- the server sends an empty ping every 30 seconds and responds to client ping frames with pong frames.
- client text and binary frames are not accepted; hydrant closes the connection when it receives one.
- when `/stream` is in `HYDRANT_THROTTLED_STREAMS` (it isn't by default), a replay from a cursor runs at most `HYDRANT_STREAM_REPLAY_SPEED` times the bandwidth the live stream would take (10 by default), or `HYDRANT_STREAM_REPLAY_FLOOR_BYTES` a second (about 80 Mbit/s) when that's more. the live tail never waits on it.
- a subscriber that falls behind the live broadcast catches up from the database at its own pace. `record` events are never skipped, but `identity` and `account` events it fell behind on are not replayed.
- if hydrant can't read the next events from the database, it closes the connection rather than skip them. reconnect with the last `id` you received as the cursor.
- hydrant sends an empty ping every 30 seconds and answers client pings with pongs.
- clients can't send text or binary frames. hydrant closes the connection if one does.
- when `/stream` is in `HYDRANT_THROTTLED_STREAMS` (it isn't by default), a replay from a cursor runs at most `HYDRANT_STREAM_REPLAY_SPEED` times the bandwidth the live stream would take (10 by default), or `HYDRANT_STREAM_REPLAY_FLOOR_BYTES` a second (about 80 Mbit/s by default) if that's more. the live tail never waits for it.
- a subscriber that falls behind the live broadcast catches up from the database at its own pace. it never misses a `record` event, but it does miss the `identity` and `account` events from while it was behind.
- if hydrant can't read the next events from the database, it closes the connection instead of skipping them. reconnect with the last `id` you got as the cursor.
- when hydrant stops, it closes the connection with close code `1012` (service restart). reconnect the same way. under systemd socket activation, the reconnect waits for the next hydrant instead of being refused.
- if sending blocks for `HYDRANT_STREAM_SEND_TIMEOUT` (30 seconds by default), hydrant sends an error text frame and closes the connection:
- if a send is stuck for `HYDRANT_STREAM_SEND_TIMEOUT` (30 seconds by default), hydrant sends this error frame and closes the connection:
```json
{
"type": "error",
```
## GET /stream/head
get the ID of the newest durable record event, and the stream it belongs to.
get the id of the newest durable record event, and the stream it belongs to.
```json
{ "id": 289455, "stream": "5b0e6c8f2a1d4e7b9c3f0a6d8e2b4c71" }
```
`id` is `null` while no events are stored. `stream` is the id of this database's stream, new for every database, so two nodes, or one that was wiped, never share one. an event id only means something on the stream it came from. identity and account events consume IDs without being persisted, so this is the newest ID a cursor replay can deliver, not the ID counter.
`id` is `null` while no events are stored. `stream` is the id of this database's stream. every database gets a fresh one, including after a wipe, and two nodes never share one. an event id only means something on the stream it came from. `id` is the newest event a cursor replay can deliver, not the id counter, because identity and account events use ids without being stored.
a consumer can snapshot the head before subscribing with `cursor=0` and treat the replay as caught up once it has seen a frame with `id` greater than or equal to the snapshot. keep an idle-timeout fallback: if the head event itself cannot be inflated, it is skipped and never delivered.
to know when a `cursor=0` replay has caught up, get the head before subscribing and wait for a frame with an `id` at or past it. keep an idle timeout as a fallback too, because if the head event itself can't be rendered, it's skipped and never arrives.
## chunks
with `HYDRANT_STREAM_CHUNK_SPAN` set, hydrant also seals the stream into immutable files a subscriber starting from nothing can read instead of replaying `/stream` from 0: they're compressed, they cost hydrant a file read instead of rendering every event again for every subscriber, and a cache can hold them.
with `HYDRANT_STREAM_CHUNK_SPAN` set, hydrant also seals the stream into immutable files that a subscriber starting from nothing can read instead of replaying `/stream` from 0. they're compressed, a cache can hold them, and serving one is just a file read for hydrant, where a replay renders every event again for every subscriber.
a chunk covers the ids `first` through `last`. like a replay, it holds only record events, never `identity` or `account` ones, and every one of its range. reading the chunks in order, then `/stream` from the id after the last one, delivers exactly what `/stream?cursor=0` does.
each chunk covers the ids `first` through `last` and holds every record event in that range. like a replay, it has no `identity` or `account` events. reading the chunks in order and then `/stream` from the id after the last one gets you exactly what `/stream?cursor=0` would. the file layout is under [chunk format](#chunk-format).
### chunk format
### GET /stream/manifest
a chunk is a [STAR](https://dasl.ing/star.html) archive, compressed with zstd. its header `meta` is a DRISL map:
list the chunks to read, in order.
```json
{
"stream": "5b0e6c8f2a1d4e7b9c3f0a6d8e2b4c71",
"generation": 41,
"through": 917503,
"chunks": [
{ "first": 786432, "last": 851967, "events": 47512, "bytes": 5104330, "sha256": "9f2c..." },
{ "first": 851968, "last": 917503, "events": 61664, "bytes": 11420553, "sha256": "41ab..." }
]
}
```
(the list is shortened here.) the chunks run from id 0 through `through` with no holes. while a chunk is being rebuilt, the list stops just before it, and `through` with it. read them all, then go on with `/stream?cursor={through + 1}&stream={stream}`. if a chunk fails partway through, you don't have to start over. continue with `/stream` from the id after the last event you got. `generation` changes whenever the list does. returns `404` when chunks are off.
### GET /stream/chunk/{sha256}
get a chunk's bytes as stored, as `application/zstd`. its sha256 is both the name and the `ETag`. chunks never change and can be cached forever, and `Range` requests work too. after a merge, the chunks it replaced stay readable for 15 minutes for subscribers still partway through an older list. a chunk hit by a redaction or erase is `404` right away.
### how the list changes
a new chunk is one span long. whenever the newest 4 chunks are the same size, hydrant merges them into one, up to 16 spans. past that the list grows by one chunk every 16 spans, and stopping there leaves a subscriber several chunks to fetch at once. hydrant builds chunks on a low priority thread and seals only one span every 30 seconds while ingest is behind. chunking a whole stream at once (after turning chunks on, or after a wipe) mostly waits for ingest to catch up.
a redaction or erase hits every chunk with that repository's events in it. those chunks drop out of the list until hydrant rebuilds them from the event rows without the bodies, the same as a replay would show them. there's no redaction event on the stream, and a subscriber that already read one of those chunks keeps what it got, just like one that was connected to `/stream` at the time.
## chunk format
a chunk is a [STAR](https://dasl.ing/star.html) archive compressed with zstd. the file has several zstd frames, and some decoders stop after the first one unless you tell them to read across frames. the archive's header `meta` is a DRISL map with these keys:
| key | value |
| :--- | :--- |
| `$type` | `"systems.gaze.hydrant.streamChunk"` |
| `stream` | the stream id, as in `/stream/head` |
| `first` | the first id the chunk covers |
| `last` | the last id the chunk covers |
each pair is one event, in id order, each id between `first` and `last` and greater than the one before. the key is 8 bytes, the id big-endian. the value is a DRISL map of the event's `/stream` frame fields, with the record as hydrant stores it:
### events
each pair is one event, in id order between `first` and `last`. the key is the event id as 8 bytes big-endian, and the value is a DRISL map with the same fields as the event's `/stream` frame, with the record as hydrant stores it:
| key | DRISL | json frame |
| :--- | :--- | :--- |
| `live` | bool | `record.live` |
| `did` | text | `record.did` |
| `rev` | text | `record.rev` |
| `collection` | text | `record.collection` |
| `rkey` | text | `record.rkey` |
| `action` | text | `record.action` |
| `record` | byte string, absent when there's no body | `record.record` |
| `cid` | tag 42 cid link, absent for a delete and when it's the cidv1 (dag-cbor, sha2-256) of `record`'s bytes | `record.cid`, as a base32 string |
| `record` | byte string, left out when there's no body | `record.record` |
| `cid` | tag 42 cid link, left out for a delete and when it's just the cidv1 (dag-cbor, sha2-256) of `record`'s bytes as they are | `record.cid`, as a base32 string |
a missing `cid` next to a `record` is the cidv1 of the bytes as they are, not of a re-encode of them.
`record` holds the record's dag-cbor bytes, and the json frame turns them into json. maps keep their cbor key order, a tag 42 link becomes `{"$link": "<base32 cid>"}`, bytes become `{"$bytes": "<padded base64>"}`, and ints, strings, bools, nulls and arrays stay as they are. a body that can't be written that way faithfully is written the way jacquard's `RawData` serializes it instead. that's a body with a float, an int past 64 bits, a `$link` or `$bytes` map key, keys out of dag-cbor order or repeated, or a `blob` map with any field besides `$type`, `ref`, `mimeType` and `size`. a body that isn't dag-cbor at all is left out of the json frame. the whole frame is
the compressed file is several zstd frames back to back, which decompress as one stream: one for the magic and header, then any number holding the pairs, then one for the end byte. that is how a merge works: a new header frame, then each chunk's frames between its header and its end, as they are, then a new end frame.
hydrant versions before the end byte wrote chunks without the `01` and `00` bytes. a newer hydrant rebuilds those from the event rows when it opens, and serves none of them meanwhile.
`record` holds the record's dag-cbor bytes. the json frame has them as json: maps keep their cbor key order, a tag 42 link becomes `{"$link": "<base32 cid>"}`, bytes become `{"$bytes": "<padded base64>"}`, ints, strings, bools, nulls and arrays map as themselves. a body this can't express faithfully (a float, an int past 64 bits, a map key `$link` or `$bytes`, keys out of dag-cbor order or repeated, a `blob` map with more or other fields than `$type`, `ref`, `mimeType` and `size`) is written the way jacquard's `RawData` serializes it, and one that isn't dag-cbor at all leaves `record` out of the json frame. the json frame is then
```
{"id":<id>,"type":"record","record":{"live":..,"did":..,"rev":..,"collection":..,"rkey":..,"action":..[,"record":<body>][,"cid":"<cid>"]}}
```
with no whitespace, which is byte for byte what `/stream` sends.
new chunks are one span each. hydrant merges the newest 4 chunks of the same size into one, up to 16 spans, so the list grows by one chunk every 16 spans and a subscriber can fetch several at once. it builds them on a low priority thread, and while ingest is behind it seals one span every 30 seconds, so catching up on a whole stream (turning chunks on, or after a wipe) waits for ingest.
a redaction or an erase reaches every chunk holding the repository's events. those chunks leave the list until hydrant has built them again from the event rows, with the bodies gone like a replay would have them. the stream itself has no redaction event, so a subscriber that already read a chunk, like one already connected to `/stream`, still holds what it read.
### GET /stream/manifest
the chunks to read, in order.
```json
{
"stream": "5b0e6c8f2a1d4e7b9c3f0a6d8e2b4c71",
"generation": 41,
"through": 917503,
"chunks": [
{ "first": 786432, "last": 851967, "events": 47512, "bytes": 5104330, "sha256": "9f2c..." },
{ "first": 851968, "last": 917503, "events": 61664, "bytes": 11420553, "sha256": "41ab..." }
]
}
```
(the list is shortened here.) the chunks run from id 0 through `through` with no holes, which can end before the newest chunk while one is being rebuilt. read them, then `/stream?cursor={through + 1}&stream={stream}`. if a chunk can't be read, the frames read so far are still a valid place to go on from: continue with `/stream` from the id after the last frame read. `generation` changes whenever the list does. `404` when chunks are off.
### GET /stream/chunk/{sha256}
the chunk's bytes as stored, `application/zstd`, whose sha256 is the name and the `ETag`. a chunk never changes, so it can be cached for good, and a `Range` request gets part of it. a merge leaves the chunks it replaced readable for 15 minutes, for subscribers part way through an older list. a chunk a redaction or erase reached is `404` at once.
with no whitespace, byte for byte what `/stream` sends.