# Ursula 0.7 - full documentation > Open-source Distributed Durable Streams over HTTP, backed by S3. These docs are for Ursula 0.7.x (0.7.6). The latest docs are at https://ursula.tonbo.io/llms.txt. Source: https://ursula.tonbo.io --- # Ursula > [!NOTE] > Ursula is built by Tonbo. Ursula is a self-hosted, distributed server for the replayable, append-only event timelines behind document edits, agent runs, workflows, and chat. It speaks the [Durable Streams Protocol](/docs/0.7/specs/durable-stream) over plain HTTP and SSE. Quorum-replicated in-memory front-ends give you single-digit-millisecond appends and live tail on top of S3 durability, with no separate broker, no batched 250 ms commits, and no S3 Express bill. - **HTTP-native.** `PUT` creates, `POST` appends, `GET` replays or tails with long-poll/SSE. Any HTTP client is a valid client. - **One timeline per resource.** A stream per document, session, workflow, room, or agent run, instead of a few high-throughput pipelines. - **Thread-per-core × multi-Raft.** Each stream hashes to one Raft group and one owner core. Hot-path requests touch that core only, with no cross-core synchronization. Hundreds to thousands of small groups co-exist per node, so a slow follower for one group never stalls another. - **Hot ring + S3 cold tier.** Writes commit at Raft quorum in an in-memory ring. Older segments flush to S3 in the background. A single `GET` transparently spans both tiers. ## Try it Start a single throwaway node with Docker (or build from source, see [Install](/docs/0.7/install)): ```bash docker run --rm -p 4437:4437 ghcr.io/tonbo-io/ursula:0.7.6 ``` Create a stream, append bytes, and read them back: ```bash curl -X PUT http://127.0.0.1:4437/demo curl -X PUT http://127.0.0.1:4437/demo/hello curl -X POST http://127.0.0.1:4437/demo/hello \ -H 'Content-Type: application/octet-stream' \ --data-binary 'hello world' curl 'http://127.0.0.1:4437/demo/hello?offset=-1' ``` Continue with the [Quick Start](/docs/0.7/quick-start) to replay and tail streams live, and [Deploy a Cluster](/docs/0.7/deploy-cluster) when you're ready for the production three-voter shape. ## Learn more - **[Why Ursula](/docs/0.7/why-ursula):** the four properties Ursula keeps that other servers force you to trade - **[Architecture](/docs/0.7/architecture/overview):** thread-per-core, multi-Raft, hot/cold tiers - **[Protocol Spec](/docs/0.7/specs/durable-stream):** Durable Streams plus [Ursula's extensions](/docs/0.7/specs/extensions) - **[ursulactl](/docs/0.7/cli):** the operator CLI for a running cluster (leadership drain, status, readiness gates) ## Credits - **[ElectricSQL](https://electric-sql.com/)** for the original Durable Streams Protocol that Ursula implements. - **[Loro](https://loro.dev/)** for the snapshot and replay extension design that Ursula adopted on top of the base protocol. --- # Why Ursula ## What Ursula keeps A new generation of event streams lives outside the broker network. Document editors, agents, and durable workflows need timelines that browsers, mobile apps, and serverless functions can read, write, and tail over the public internet. That asks for HTTP-native, distributed, S3-backed infrastructure, not the SDK-locked, single-network shape Kafka-style brokers were built for. The [Durable Streams Protocol](/docs/0.7/specs/durable-stream) nails that wire format, but its reference server is a single process: a node loss is data loss. The other servers we evaluated each force you to give up one of four things this primitive deserves to keep: - **Open-source self-hosting.** - **Low write latency** (sub-50 ms appends, no batching window required). - **Plain S3 economics** (cold tier on standard S3, no S3 Express tier, no per-GB SaaS markup). - **Quorum-replicated durability** (acknowledged writes survive a single-node failure). Ursula keeps all four. For the head-to-head numbers, see [How Ursula compares](/docs/0.7/competitive-comparison). ## One timeline per entity Most event systems multiplex many entities into shared channels and rebuild per-entity state downstream. Ursula inverts that: each document, session, task, room, or agent run gets its own durable timeline. Writers append directly to it and readers resume from its offsets. Treat it as a per-entity durable log runtime, purpose-built for that pattern rather than a general event backbone. ## What you get from one stream per entity - **Replayable recovery.** When a worker, sandbox, or agent restarts, replay its log and continue. - **Live tails with simple clients.** Catch-up reads, long-poll, or SSE, all over plain HTTP. - **Lifecycle in the same primitive.** Snapshots, bootstrap, and TTL ride the timeline instead of being separate infrastructure. ## The tradeoff Ursula makes one timeline per entity cheap. In exchange, it isn't the most general primitive for cross-system event distribution or arbitrary stream processing. Keeping the per-entity model simple is what makes recovery, inspection, and operation tractable for long-running application state. For latency and durability numbers, see [Architecture](/docs/0.7/architecture/overview) and [Competitive comparison](/docs/0.7/competitive-comparison). --- # Install This page gets a **single Ursula node** running on your machine so you can try the API. There are two ways to do it: | Pick | When | | ---- | ---- | | **Docker** | You just want to try Ursula. One command, no toolchain. | | **Build from source** | You want local binaries, plan to hack on Ursula, or can't run Docker. | Both start a single, non-replicated node on port `4437` that keeps nothing across restarts. The source build runs the in-memory engine. The Docker image runs a single Raft node whose WAL lives in a temporary directory inside the container. For a production cluster, see [Deploy a Cluster](/docs/0.7/deploy-cluster). ## Docker Every release publishes a multi-arch image to GHCR: ```bash docker run --rm -p 4437:4437 ghcr.io/tonbo-io/ursula:0.7.6 ``` The image runs as a non-root user and contains both `ursula` (the server) and `ursulactl` (the operator CLI). Images are published only by the reviewed stable-release workflow, so pin the version you want to run. ## Build from source You need rustup, a C compiler, and `pkg-config`. The repository pins the toolchain in `rust-toolchain.toml`, so rustup selects the right nightly automatically. ### Installing the prerequisites (macOS, Debian/Ubuntu) On macOS: ```bash brew install rustup pkg-config rustup-init -y ``` On Debian or Ubuntu: ```bash sudo apt-get update sudo apt-get install -y build-essential pkg-config curl curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` Clone, build, and start the server: ```bash git clone https://github.com/tonbo-io/ursula.git cd ursula cargo build --release -p ursula -p ursula-ctl ./target/release/ursula server ``` The server is now listening on `127.0.0.1:4437`. The build also produces `target/release/ursulactl`, the operator CLI you will use once a cluster is up. ## Verify With the node running, from another terminal: ```bash curl http://127.0.0.1:4437/__ursula/metrics ``` A JSON snapshot of runtime state means the node is up. ## Next - [Quick Start](/docs/0.7/quick-start): create streams, append, replay, and tail live over SSE - [Deploy a Cluster](/docs/0.7/deploy-cluster): the production three-voter shape with Helm - [Configuration](/docs/0.7/configuration): persistence, presets, and S3 cold storage --- # Quick Start This page assumes a single node is already running on `127.0.0.1:4437`. Starting one is a single Docker or Cargo command, see [Install](/docs/0.7/install). The default node keeps nothing across restarts. For disk-backed persistence, set `raft.wal.path` (see [Configuration](/docs/0.7/configuration)). Drive the HTTP API with `curl` from another terminal. ## Validate the bucket ID (optional) Buckets are implicit: a bucket exists once a stream is created in it, so this call is optional. It only validates the bucket ID (`201`, or `400` for an invalid one): ```bash curl -X PUT http://127.0.0.1:4437/demo ``` ## Create a stream ```bash curl -X PUT http://127.0.0.1:4437/demo/hello ``` ## Append data ```bash curl -X POST http://127.0.0.1:4437/demo/hello \ -H 'Content-Type: application/octet-stream' \ --data-binary 'first message' curl -X POST http://127.0.0.1:4437/demo/hello \ -H 'Content-Type: application/octet-stream' \ --data-binary 'second message' ``` Each successful append returns `204 No Content` with a `Stream-Next-Offset` header. Add `Producer-Id` / `Producer-Epoch` / `Producer-Seq` if you need [exactly-once retries](/docs/0.7/concepts/exactly-once-writes). ## Read everything from the beginning ```bash curl -i 'http://127.0.0.1:4437/demo/hello?offset=-1' ``` The body contains the appended bytes. Response headers include `Stream-Next-Offset`, `Stream-Up-To-Date`, and an `ETag`. ## Subscribe for live updates Open a second terminal: ```bash curl -N 'http://127.0.0.1:4437/demo/hello?offset=-1&live=sse' ``` `-N` keeps `curl` from line-buffering the SSE stream. Append more data from the first terminal and you'll see it arrive immediately as `event: data` lines. Binary streams are delivered as raw base64 text in `event: data` payloads (`Stream-Sse-Data-Encoding: base64`). See [binary SSE](/docs/0.7/concepts/binary-sse) for details. ## Inspect runtime state For a multi-node cluster the canonical day-2 tool is [`ursulactl`](/docs/0.7/cli). It speaks to every node, summarises leadership, and wraps restarts in safe drain and catch-up steps. For a single local node you can either point it at a one-line manifest: ```bash cat > /tmp/local.json <<'JSON' {"nodes": [{"id": 1, "http_url": "http://127.0.0.1:4437", "host": "127.0.0.1"}]} JSON ursulactl status --config /tmp/local.json ``` Or hit the underlying JSON endpoint directly: ```bash curl http://127.0.0.1:4437/__ursula/metrics ``` The raw endpoint is also what `ursulactl` consumes. Use it directly when you want the full snapshot or are building custom tooling. ## Next steps - [Deploy a Cluster](/docs/0.7/deploy-cluster): the production three-voter shape with Helm - [Configuration](/docs/0.7/configuration): persistence, presets, and S3 cold storage in one place - [ursulactl](/docs/0.7/cli): the operator CLI you'll use once a cluster is up - [API overview](/docs/0.7/api/overview): the HTTP surface Ursula currently exposes - [Streams](/docs/0.7/concepts/streams): the core stream abstraction and lifecycle - [Architecture](/docs/0.7/architecture/overview): thread-per-core, multi-Raft internals --- # Clients Ursula speaks plain HTTP and Server-Sent Events. There is no required client library. Any HTTP client in any language works. The examples elsewhere in these docs use `curl` because it's universal. The same routes, headers, and query parameters apply to every other client. ## Minimal examples ```bash # create a bucket and stream curl -X PUT http://127.0.0.1:4437/demo curl -X PUT http://127.0.0.1:4437/demo/hello # append curl -X POST http://127.0.0.1:4437/demo/hello \ -H 'Content-Type: application/octet-stream' \ --data-binary 'hello world' # catch-up read curl 'http://127.0.0.1:4437/demo/hello?offset=-1' # live tail curl 'http://127.0.0.1:4437/demo/hello?offset=-1&live=sse' ``` ```python import requests base = "http://127.0.0.1:4437" requests.put(f"{base}/demo") requests.put(f"{base}/demo/hello") requests.post( f"{base}/demo/hello", headers={"Content-Type": "application/octet-stream"}, data=b"hello world", ) # catch-up read resp = requests.get(f"{base}/demo/hello", params={"offset": -1}) print(resp.content) # live tail with SSE with requests.get( f"{base}/demo/hello", params={"offset": -1, "live": "sse"}, stream=True, ) as r: for line in r.iter_lines(): if line: print(line.decode()) ``` ```ts const base = "http://127.0.0.1:4437"; await fetch(`${base}/demo`, { method: "PUT" }); await fetch(`${base}/demo/hello`, { method: "PUT" }); await fetch(`${base}/demo/hello`, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: "hello world", }); // catch-up read const data = await (await fetch(`${base}/demo/hello?offset=-1`)).text(); // live tail with native EventSource const es = new EventSource(`${base}/demo/hello?offset=-1&live=sse`); es.addEventListener("data", (e) => console.log(e.data)); ``` ## From a browser The examples above assume same-origin access. A page served from a different origin than the gateway needs the gateway to allow that origin — see [cross-origin reads](/docs/0.7/security#cross-origin-reads-opt-in): ```bash ursula gateway ... --cors-allowed-origin https://app.example.com ``` Two things to know once it is on: - **Read your continuation headers.** The gateway sends `Access-Control-Expose-Headers: *`, so `Stream-Next-Offset` and friends are readable. Without exposure a browser can read one page and never advance, which looks like the stream ending. If a `fetch` succeeds but `response.headers.get("stream-next-offset")` is `null`, the origin is not allowing your origin. - **`EventSource` cannot send `Authorization`.** It has no header API, so it only works against `public_read` streams. For a private live tail, use `fetch` and read the body: ```ts const response = await fetch(`${base}/${bucket}/hello?offset=-1&live=sse`, { headers: { Authorization: `Bearer ${token}` }, }); const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); ``` A subscription ends when its credential expires, and says so: the final frame is `event: credential-expired`. Treat it as a reconnect signal rather than end of stream — refresh the token and re-read from the last `Stream-Next-Offset` you saw. Any other termination deserves the same handling. ## Notes for client implementers - After every read and append, the server returns `Stream-Next-Offset`. Track it. Use it as the `offset` query parameter on the next read. Don't construct offsets manually. They're opaque. - For binary streams over SSE, `data` events carry raw base64 text and the response includes `Stream-Sse-Data-Encoding: base64`. Decode the data event first, then interpret the bytes using `Stream-Data-Content-Type`. See [Binary SSE](/docs/0.7/concepts/binary-sse). - For exactly-once writes, send `Producer-Id`, `Producer-Epoch`, `Producer-Seq` headers and retry on network errors. The server deduplicates. See [Exactly-once writes](/docs/0.7/concepts/exactly-once-writes). - For conditional writes, use `Stream-Seq`: the server accepts an append only when its value is bytewise greater than the last one accepted on the stream. Several writers can extend one log safely by sending the number of events they read. See [Conditional writes](/docs/0.7/concepts/conditional-writes). --- # Deploy a Cluster Production Ursula is a static-membership Raft cluster: three voting nodes across availability zones, a durable Raft log per node, and a shared S3 bucket for the cold tier. The recommended way to run it is **Kubernetes via the Helm chart**. Bring a Kubernetes cluster with a StorageClass for the voters' volumes and an S3 bucket for the cold tier. The chart installs Ursula into it. If you are not running Kubernetes, see [bare metal](#without-kubernetes-bare-metal--vms) at the end. ## Helm The chart and images are published to GHCR on every release. One command starts a three-voter cluster: ```bash helm install ursula oci://ghcr.io/tonbo-io/charts/ursula --version 0.7.6 ``` The chart defaults to the `ghcr.io/tonbo-io/ursula` image pinned to the chart's `appVersion`. The default install runs three voters and 64 Raft groups, with durable per-pod Raft logs on PVCs, a headless peer Service, a client Service, and a quorum-protecting PodDisruptionBudget. For production, add shared S3 cold storage and workload identity: ```yaml s3: bucket: my-ursula-bucket region: us-east-1 prefix: ursula-prod coldStorage: enabled: true serviceAccount: annotations: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/ursula-s3 ``` With cold storage enabled the chart also turns on cold-chunk compaction and lets Ursula pick S3 for Raft snapshots (`snapshotStore.backend: auto`). Set `coldStorage.compaction.enabled=false` or `snapshotStore.backend=inline` to opt out. For MinIO or another S3-compatible backend, set `s3.endpoint`. Prefer workload identity over static S3 credentials. To use a registry mirror, set `global.image.repository`, `global.image.tag`, and optionally `global.imagePullSecrets`. `server.replicaCount` controls the voter set for a **fresh** cluster and supports `1`, `3`, and `5`. Production should use `3`, or `5` only when tolerating two simultaneous voter failures justifies the larger write quorum. Changing it on an initialized cluster is not safe Raft voter reconfiguration. Safe scaling needs the future operator workflow. ### On AWS (EKS) The chart's [`examples/production-eks.yaml`](https://github.com/tonbo-io/ursula/blob/main/charts/ursula/examples/production-eks.yaml) is a three-zone starting point: three voters on `gp3` volumes, S3 cold storage and snapshots, three gateways, and a two-replica indexer pool with its own workload identity. It needs: - an EKS cluster with nodes in three availability zones, - the EBS CSI driver and a StorageClass named `gp3`, - an S3 bucket, - IAM roles for the Ursula and indexer service accounts (IRSA or EKS Pod Identity), each limited to its own S3 prefix. The [chart README](https://github.com/tonbo-io/ursula/tree/main/charts/ursula#prerequisites) lists the details. Replace the bucket and the role ARNs in the example, then install it: ```bash helm install ursula oci://ghcr.io/tonbo-io/charts/ursula --version 0.7.6 \ --namespace ursula --create-namespace -f production-eks.yaml helm test ursula --namespace ursula ``` ## Verify ```bash helm test ursula ``` Gate on this test, not on `helm install --wait`. Helm does not wait for a StatefulSet with the `OnDelete` update strategy, so `--wait` can report the release deployed before any voter is ready. The test mounts the chart-generated cluster manifest and runs `ursulactl wait-ready`. It requires the expected group inventory and leaders, a complete uniform voter set in each maintenance report, no uncertain maintenance fence, and a 200 response from every node's `/__ursula/ready` probe. To query a node directly: ```bash kubectl port-forward svc/ursula 4437:4437 curl http://127.0.0.1:4437/__ursula/metrics ``` ## Production notes - Run three voters across three availability zones, each with its own zonal persistent volume for the Raft log. Never use ephemeral voter data in production. The chart always gives each voter pod a `raft-data` volume claim. - Shared S3 is required for any multi-node cluster: replicas must be able to read chunks flushed by any leader. - Put stateless gateway replicas behind authenticated TLS ingress and keep voter and peer Services private. Ursula itself has no TLS or auth (see [Security](/docs/0.7/security)). - Kubernetes rolling updates do not transfer Raft leaders on their own. For an operationally safe restart, wrap the platform's restart in [ursulactl's maintenance verbs](/docs/0.7/cli): `ursulactl drain` the node, let Kubernetes restart the pod, then `ursulactl wait --node N` and `undrain` before moving to the next one. See [Operations](/docs/0.7/operations) for day-2 work. - The StatefulSet readiness probe calls `GET /__ursula/ready` and removes a pod from service during WAL disk pressure. Cluster-wide Raft readiness still comes from `helm test` or `ursulactl wait-ready`. The mutating admin surface is bound to pod loopback (`127.0.0.1:4438`) and is reached with `kubectl port-forward`. - `helm uninstall` leaves the graceful-rollout state ConfigMap (`-rollout-state`, for example `ursula-rollout-state`) behind. Delete it before you install again under the same name, so the rollout hook does not resume a rollout of the old release. - Clusters that serve the [SQLite VFS](/docs/0.7/examples/sqlite-vfs) need per-group admission caps of at least 64 MiB (`standard` or `large` preset) so a 32 MiB commit can be admitted. ## Optional: event-time indexer (experimental) `ursula-indexer` is an optional worker pool that builds queryable event-time indexes from `application/json` and `application/x-ndjson` streams, kept outside the voter processes so query and compaction work never touches commit latency. It is experimental: its API and S3 format may change in a minor release, which then needs a new or emptied `indexer.s3.prefix` and re-registration. ```yaml indexer: enabled: true replicaCount: 2 s3: prefix: indexes serviceAccount: annotations: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/ursula-index ``` Register streams dynamically with `PUT /v1/indexes/{id}` on the internal indexer Service; see [Browser Telemetry](/docs/0.7/examples/browser-telemetry#event-time-queries-experimental). No Helm upgrade is needed. The Service is an internal ClusterIP. If remote applications need read access, front it with an authenticated, path-aware proxy and never expose the registration or administration routes. ## Without Kubernetes (bare metal / VMs) The same static-membership model works by hand. Every node runs the same config file, and only the node ID differs: ```toml [server] listen = "0.0.0.0:4437" [raft] group_count = 256 init_membership_per_group = true [raft.wal] path = "/var/lib/ursula" [storage.cold] backend = "s3" root = "ursula-prod" [storage.cold.s3] bucket = "my-ursula-bucket" region = "us-east-1" [[raft.peers]] node_id = 1 url = "http://10.0.0.1:4437" [[raft.peers]] node_id = 2 url = "http://10.0.0.2:4437" [[raft.peers]] node_id = 3 url = "http://10.0.0.3:4437" ``` Start each node with its own ID: ```bash ursula --config /etc/ursula/ursula.toml --node-id 1 # node 2 and 3 likewise ``` `init_membership_per_group = true` bootstraps each group on the first start of a fresh cluster. Keep it on every node across restarts: a replica that ever held its group never initializes it again, and an empty replica initializes a group only when every configured voter reports it empty and no leader (see [Rejoining a wiped node](/docs/0.7/operations#rejoining-a-wiped-node)). Verify the cluster with a one-file manifest: ```bash cat > cluster-manifest.json <<'JSON' { "nodes": [ {"id": 1, "http_url": "http://10.0.0.1:4437", "host": "10.0.0.1"}, {"id": 2, "http_url": "http://10.0.0.2:4437", "host": "10.0.0.2"}, {"id": 3, "http_url": "http://10.0.0.3:4437", "host": "10.0.0.3"} ] } JSON ursulactl wait-ready --config cluster-manifest.json --expected-groups 256 ursulactl status --config cluster-manifest.json ``` Every config key (peers, listeners, WAL, cold-tier tuning) is documented in [Configuration](/docs/0.7/configuration). --- # Configuration Everything configurable in Ursula lives on this page. Ursula is configured by a TOML config file, an optional resource preset, and a small set of CLI overrides, merged in this order: ```text built-in defaults < preset defaults < config file < CLI overrides ``` The normal startup command is: ```bash ursula --config /etc/ursula/ursula.toml --node-id 1 ``` With no config file, `ursula` falls back to the `default` preset, which starts a single-node in-memory runtime without Raft, and nothing survives a restart. That's what [Install](/docs/0.7/install) uses. Every other preset, and a config file loaded without `--preset default`, runs Raft on the disk WAL described under [Raft](#raft). ## CLI flags | Flag | Default | Notes | | ---- | ------- | ----- | | `--config FILE` | searches `./ursula.toml`, `/etc/ursula/ursula.toml`, then user config paths | TOML config file | | `--preset PRESET` | `default` when no config file is found, otherwise none | Resource preset: `default`, `dev`, `tiny`, `small`, `standard`, or `large` | | `--node-id ID` | config file or preset | Overrides `raft.node_id` | CLI `--node-id` intentionally overrides any `raft.node_id` value in the config file so the same file can be mounted on every node. Helm derives it from the StatefulSet ordinal. ## Resource presets `--preset` supplies a base configuration before the config file is merged, and config-file values win. Pick by node size: | Preset | Intended use | Main defaults | | ------ | ------------ | ------------- | | `default`, `dev` | Local development | Single-node in-memory runtime without Raft | | `tiny` | Memory-bound tests or very small nodes | 64 groups, 64 MiB cold cache, 4 MiB cold flushes, 2 cold writes, 8 MiB hot/admission caps, 64 MiB HTTP in-flight body budget | | `small` | Small nodes or cost-sensitive tests | 128 groups, 64 MiB cold cache, 4 MiB cold flushes, 2 cold writes, 16 MiB hot/admission caps, 64 MiB HTTP in-flight body budget | | `standard` | Production baseline | 256 groups, 256 MiB cold cache, 8 MiB cold flushes, 4 cold writes, 64 MiB hot/admission caps, 256 MiB HTTP in-flight body budget | | `large` | Larger nodes with more cache and write headroom | 512 groups, 512 MiB cold cache, 16 MiB cold flushes, 8 cold writes, 128 MiB hot/admission caps, 512 MiB HTTP in-flight body budget | ## Server and runtime | Config key | Default | Purpose | | ---------- | ------- | ------- | | `server.listen` | `127.0.0.1:4437` | Client/API listener | | `server.cluster_listen` | none | Optional separate Raft/cluster listener | | `runtime.cpu_affinity` | automatic | On Linux, `true` forces pinning and `false` disables it; omitted pins only when cpuset size equals core_count | | `runtime.core_count` | `available_parallelism` | Owner worker threads; Linux automatically pins only when the allowed cpuset matches this count. Journal writers and the main Tokio runtime use additional threads | | `runtime.live_read_max_waiters_per_core` | `65536` | SSE waiter cap per core. `0` disables the limit | | `observability.tokio_console` | `false` | Enables Tokio console when the binary is built with the feature | Without `server.cluster_listen`, `server.listen` serves both client/API and Raft/cluster routes, and each `[[raft.peers]].url` should point at that address. With `server.cluster_listen` set, `server.listen` is client/API-only and peer URLs must point at the cluster listener instead. Note that the same peer URL is currently also used for HTTP leader redirects. A client that talks to a node directly follows it, so it must be reachable by such clients as well as by peers. `ursula gateway` follows a redirect to an upstream directly when its `--upstream` equals the peer URL, and searches its upstreams for the leader otherwise. ## Raft | Config key | Default | Purpose | | ---------- | ------- | ------- | | `raft.node_id` | none | This node's stable ID, usually overridden per host with `--node-id` | | `raft.append_transport_budget_bytes` | `134217728` | Node-wide charged append bytes per direction, shared across cores and peers; excludes tonic/socket buffers | | `raft.group_count` | `core_count × 16` | Total Raft groups. Higher values improve stream-to-group hash spread | | `raft.wal.path` | none | Directory of the Raft WAL. Required when `raft.peers` is set. Raft logs live under `PATH/raft-log`, and removing the directory wipes state cleanly. Without it, a single node runs its WAL in a temporary directory that is removed on clean shutdown | | `raft.wal.fsync` | `never` | When journal appends reach stable storage. `never` acknowledges ordinary data appends from the page cache. Membership batches are always fsynced before acknowledgment. `always` acknowledges a batch after `fsync`. Votes, each group's log state and the node's run state are always written with `fsync` | | `raft.wal.segment_size` | `64MiB` | Size at which each core's journal starts a new segment. Purge deletes whole segments, so this is also how much disk space comes back at a time. At least `4KiB` | | `raft.wal.cache_size` | `256MiB` | Recent Raft entries kept in memory across the node, split evenly between its groups. Older entries, such as a lagging follower's catch-up, are read from disk | | `raft.wal.min_available_size` | `512MiB` | Reject writes and mark `/__ursula/ready` unavailable below this much free WAL-filesystem space. `0` disables the guard | | `raft.wal.resume_available_size` | `1GiB` | Free space required before disk-pressure admission clears. Must exceed the minimum | | `raft.peers` | empty | Static gRPC peer list, with one `[[raft.peers]]` entry per voter, including this node | | `raft.init_membership_per_group` | `false` | Per-group membership bootstrap. One voter per group initializes it once every configured voter reports the group empty and no leader. A replica that ever held its group never initializes it again, so keep it `true` across restarts. The Helm chart sets `true` | The Raft WAL is always the per-core disk journal. A node with `raft.peers` set refuses to start without `raft.wal.path`. A single node without a path creates a fresh `ursula-wal-*` directory under the system temporary directory, logs its location at startup, and removes it on a clean shutdown (`SIGTERM` or Ctrl-C). A failed start, or a shutdown that cannot close the WAL, leaves the directory in place. Its streams survive no restart. Set `raft.wal.path` to keep them. With `fsync = "never"`, a process crash loses nothing because the page cache survives it, and a graceful shutdown `fsync`s every journal before it exits. A host crash or power loss can drop the unsynced tail of that replica's journal. The replica restarts from the frames that still verify and reports that it is recovering. Each of its groups stays behind a recovery gate: it does not campaign or vote until it has applied a fresh barrier from the group's leader, so it never helps elect a leader that lacks a write it acknowledged. While at most a minority of a group's voters lost their tail, the other voters hold every acknowledged write and the group keeps serving. If a majority lost it at once, the group has no leader and refuses writes until an operator accepts the loss (see [Operations](/docs/0.7/operations#recovering-after-a-host-crash)). With `fsync = "always"`, a host crash loses no acknowledged write. Every batch waits for an `fsync`, which costs write latency. A host crash can still leave a complete invalid frame after the last `fsync` in a journal's newest segment. Recovery cannot tell it from media corruption, so it gates that core's groups as above. A group gated on a majority of its voters, or a single-voter group, then waits for an operator although nothing was lost (see [Operations](/docs/0.7/operations#recovering-after-a-host-crash)). ## Cold storage Ursula keeps recent data in an in-memory hot ring on every replica and flushes older segments and snapshot blobs to a cold backend. Multi-node clusters should use S3 or an S3-compatible object store so every replica can read chunks flushed by any leader. All replicas must point at the same shared bucket. ```toml [storage.cold] backend = "s3" root = "ursula-prod-20260518" [storage.cold.s3] bucket = "my-ursula-bucket" region = "us-east-1" # endpoint = "http://127.0.0.1:9000" # access_key_id = "AKIA..." # secret_access_key = "..." # session_token = "..." # server_side_encryption = "aes256" # default; "aws-kms" or "none" # kms_key_id = "arn:aws:kms:..." # customer managed key for "aws-kms" ``` When `access_key_id` and `secret_access_key` are omitted, Ursula uses the standard AWS SDK credential chain (instance profile, environment, profile, and so on). Prefer that over static keys. Set `endpoint` for S3-compatible stores such as MinIO, R2, or TOS. Every cold-tier object write requests server-side encryption by default (`server_side_encryption = "aes256"`, i.e. SSE-S3) — free on AWS S3, and Raft snapshot uploads inherit the same setting. `aws-kms` switches to SSE-KMS, using the AWS managed key unless `kms_key_id` names a customer managed key. MinIO honors SSE headers only when a KMS/KES is configured; for a MinIO deployment without one, set `server_side_encryption = "none"` explicitly or writes fail with an SSE error. | Config key | Default | Purpose | | ---------- | ------- | ------- | | `storage.cold.backend` | `none` | `none`, `memory`, or `s3` | | `storage.cold.root` | none | Prefix prepended to every cold key. Use a date-stamped value for benchmark runs so cleanup can't touch production data | | `storage.cold.s3.bucket` | none | Required when `backend = "s3"` | | `storage.cold.s3.server_side_encryption` | `aes256` | SSE mode for every object write: `aes256` (SSE-S3), `aws-kms`, or `none` | | `storage.cold.s3.kms_key_id` | none | Customer managed KMS key; only valid with `aws-kms` | | `storage.cold.s3.probe_timeout` | `5s` | Deadline of the snapshot-store health probe, retries included. A probe whose first attempt fails fast still has time for one retry, while an attempt that hangs runs into this deadline. Must be non-zero | | `storage.cold.s3.unhealthy_ticks` | `3` | Bad health ticks, with no good tick between them, before the node sheds its leaderships. Must be non-zero | | `storage.cold.s3.heal_ticks` | `2` | Good health ticks in a row before a node that shed may lead again. Must be non-zero | | `storage.cold.flush_interval` | `1s` | Background flush worker tick interval | | `storage.cold.flush_size` | `8MiB` | Target bytes flushed per group per tick | | `storage.cold.flush_max_concurrency` | `4` | Parallel cold writes in flight | | `storage.cold.max_hot_size_per_group` | `64MiB` | Backpressure ceiling: when a group's hot bytes exceed this, new writes get HTTP `503` until the flush catches up. Explicit `0` disables the cap | | `storage.cold.flush_max_hot_age` | `5m` | A stream's hot tail older than this is flushed whole even when its group is below the flush threshold. `0` disables | | `storage.cold.compaction_enabled` | `true` | Background compaction: merges small chunks that flushes recorded as compaction debt (no bucket listing) and rewrites packed slices once a stream holds 64 of them | | `storage.snapshot.backend` | `auto` | `auto` (S3 snapshots when `storage.cold.backend = "s3"`, inline otherwise), `inline`, or `s3` | | `raft.snapshot_log_budget` | `1GiB` | Node Raft-log budget of the snapshot driver. A group snapshots once its log since the last snapshot reaches the larger of this budget over twice the group count (at most `16MiB`) and twice its last snapshot's size. Above three quarters of the budget a pressure pass snapshots the groups that hold the most log first, one at a time, until the node would fall to half the budget. Past the budget it runs more builds at once, in even steps up to `raft.snapshot_build_max_concurrency` before twice the budget. With the default of 2 that is two builds past the budget. With 4 it is two past the budget, three past 1.33 times it and four past 1.67 times it. Over twice the budget the node refuses writes with a body until its log is below 1.5 times the budget | | `raft.snapshot_build_max_concurrency` | `2` | Snapshot builds the node runs at once. Each build holds its group's captured state (the hot window, at most `storage.cold.max_hot_size_per_group`, and the streams' metadata) and, while it uploads, its compressed snapshot, about as large again, and it sends its snapshot-store requests one after another. The snapshot driver starts a build only while fewer run than the node's log calls for: one below `raft.snapshot_log_budget` and more past it. Builds it started past the budget finish after the log falls below it. A snapshot an operator triggers while every build slot is taken is deferred. With the driver off (`storage.snapshot.drive_interval = 0`), OpenRaft's own policy starts the builds, up to this many at once, where the default of 1 before 0.7.4 ran one. Set `1` to keep one build at a time, as before 0.7.4. Raise it, to `4` for example, when the node keeps reaching twice the budget with every build in use (`raft_snapshot_builds_in_flight_max` at this value) and its memory holds that many builds of its largest groups. The server refuses to start with `0` or more than `16` | | `raft.snapshot_pressure_max_groups_per_tick` | `16` | Groups one snapshot driver pass starts while the node's log is below `raft.snapshot_log_budget`. Past the budget a pass starts this many for each build it keeps in flight | Hot bytes here are payload bytes: the hot window keeps no per-message bookkeeping, so admission and flush thresholds charge exactly the unflushed payload. Flush defaults are conservative, so tune them under load. A typical benchmark profile drops the interval to `200ms`, raises concurrency to `32`, and bumps the per-group ceiling. Advanced deployments can split the flush threshold and batch size with `storage.cold.flush_min_hot_size` and `storage.cold.flush_max_size`, but the single `storage.cold.flush_size` knob is the normal path. Once a group's accumulated hot payload reaches the threshold, Ursula packs eligible slices from that group into one immutable object up to the configured maximum; single-stream slices of 1 MiB or more keep the direct-object layout, and smaller ones are packed even when they are alone. With S3 snapshots, a health gate checks the store every `storage.snapshot.drive_interval` (`5s` by default). Setting `drive_interval = "0s"` turns off the snapshot driver and this gate with it. Each tick probes the store and reads the node's cold-flush writes. A tick is bad when the probe fails or misses its deadline, or when a write failed within the flush window and no write has succeeded since. The window is as long as the S3 client can take to give up on a write: `storage.cold.s3.timeout` for each of its `max_retries` plus one attempts, the backoff between them, and the flush worker's pause between passes. That is 55 seconds with the defaults. A tick in which writes both failed and succeeded neither ends a bad streak nor adds to it. One failed probe is not enough on its own, and once a later write succeeds a failed write weighs only on its own tick. A store that refuses writes but answers the probe is still caught. After `storage.cold.s3.unhealthy_ticks` bad ticks with no good tick between them the node stops campaigning and hands its leaderships to its peers. With the defaults that is 10 to 20 seconds into an outage when the probes fail, about 10 to 30 seconds when only writes fail (sooner when the store refuses them outright), and about a minute when every write attempt hangs until its timeout. While a tick is bad the node also holds routine snapshots back, though a log-pressure pass still runs. After `storage.cold.s3.heal_ticks` good ticks in a row it may lead again. For a node shed for failed writes, the good ticks start with the first write that succeeds after the last failed one. A node that has handed off every leadership flushes nothing, so its good ticks start only once its last failed write leaves the window, about a minute after that write with the defaults. A shedding node hands off at most four leaderships per tick, the groups with the most unflushed bytes first, and only to peers that may campaign. A peer that is draining, waiting at its recovery gate, or shedding for its own snapshot store, cluster egress or WAL disk is passed over. The gate asks each peer for its leadership-shed state and waits `governance.leadership_balance.peer_timeout` (`500ms` by default, must be non-zero) for the answer, the same timeout the leadership balancer uses. A peer that does not answer in time is treated as unable to campaign. A handoff is done once another node leads the group, and one that Raft abandons is handed off again on a later tick. A node with N leaderships therefore evacuates in about `ceil(N / 4)` ticks, for example 22 ticks or 110 seconds for 86 leaderships at the default interval. The cap is per gate. The cold-health gate (`governance.cold_health`, every `2s` by default) drains the same way and independently, so a node that sheds for both moves up to four leaderships per 2 seconds plus four per 5 seconds. The leadership balancer (`governance.leadership_balance`) also keeps moving leaderships off a node that holds more than its fair share, up to `max_per_tick` per balancer tick. The cluster-egress gate (`governance.cluster_probe`) still hands every leadership off at once, because a node that cannot reach a quorum cannot replicate, and it plans again on every tick for the ones still pending. Its questions to the peers travel the path it judges impaired, so a lost answer says little about a peer. It passes over a peer that answered that it may not campaign. A peer that did not answer may take a leadership's first handoff, and the Raft handoff check decides. A leadership whose handoff did not move goes only to a peer that answered that it may campaign, so a peer that never answers costs each leadership at most one stalled handoff per shed. On a tick when a peer answered that it may not campaign, the gate passes over the peers that did not answer too, because the shed may be cluster-wide. Raft snapshotting does not force a partial cold flush. Payload below the cold threshold stays in the bounded hot tail and is included in the next group snapshot, so a short snapshot interval does not turn small tails into small cold objects. Helm and the EC2 scripts map their deployment inputs onto these same keys, so this table applies to every deployment method. ## Complete examples Disk-backed single node (streams survive a restart): ```toml [server] listen = "127.0.0.1:4437" [raft] node_id = 1 group_count = 16 [raft.wal] path = "./data" ``` ```bash ursula --config ./ursula.toml ``` Three-node durable cluster (same file on each host, override the node ID per host): ```toml [server] listen = "0.0.0.0:4437" [runtime] core_count = 16 [raft] node_id = 1 group_count = 256 init_membership_per_group = true [raft.wal] path = "/var/lib/ursula" [storage.cold] backend = "s3" root = "ursula-prod-20260518" [storage.cold.s3] bucket = "my-ursula-bucket" region = "us-east-1" [[raft.peers]] node_id = 1 url = "http://10.0.0.1:4437" [[raft.peers]] node_id = 2 url = "http://10.0.0.2:4437" [[raft.peers]] node_id = 3 url = "http://10.0.0.3:4437" ``` ```bash ursula --config /etc/ursula/ursula.toml --node-id 1 ``` See [Deploy a Cluster](/docs/0.7/deploy-cluster) for the full deployment walk-through. --- # Security > [!WARNING] > Ursula does not terminate TLS, authenticate clients, or restrict admin endpoints. Treat the listening port as fully trusted. Run it on a private network behind a reverse proxy that owns TLS termination and request authentication. The current `v0.x` security model is deliberately narrow. Ursula is built to slot behind your existing edge layer, not to be one. ## What Ursula does - **Quorum-acknowledged writes.** An append is acknowledged only after a majority of voters has replicated it. - **Per-group backpressure.** When a group's hot ring exceeds `storage.cold.max_hot_size_per_group`, appends to that group return `503` with `Retry-After` until cold flush catches up. Per-group, not global or per-client. - **Stream-level isolation.** Streams hash to disjoint Raft groups and disjoint owner cores. A hot stream on one group cannot starve writes on a different group on a different core. ## What Ursula does not do Handle the following outside Ursula: - **TLS / HTTPS.** The public listener serves plain HTTP. No built-in `rustls`. - **Inter-node encryption.** Peer gRPC (Raft heartbeats, append-entries, snapshots, and leader-read checks) runs over h2c. Peers must share a private network. Non-leader HTTP writes return a `307` redirect to the current group leader rather than being forwarded over gRPC. - **API authentication on nodes.** Ursula nodes themselves accept any caller with network reach. Bearer-token validation is available as an opt-in feature of the gateway (see below); node listeners must stay on a private network either way. - **Authorization / multi-tenancy on nodes.** Nodes enforce no per-user, per-bucket, or per-scope ACLs. The gateway's opt-in access control provides a bucket-level tenant boundary; anything finer stays upstream. - **Admin endpoint isolation.** `/__ursula/metrics`, `/__ursula/flush-cold/*`, `/__ursula/raft/*`, and the public stream endpoints share the same listener with no auth gate. - **Per-client rate limiting.** A single noisy client can saturate a core's mailbox or a group's hot ring. - **Health/readiness endpoints.** `GET /__ursula/ready` is unauthenticated on the client plane and reports only readiness plus WAL free-space guard state. `/__ursula/metrics` contains substantially more operational detail; keep the node listener private. - **At-rest encryption beyond the cold tier.** Cold-tier S3 writes (including Raft snapshots) request SSE-S3 by default (`storage.cold.s3.server_side_encryption`, switchable to `aws-kms` or `none`). The hot ring is in memory; WAL and Raft log directories live on disk in plaintext — use full-disk encryption at the host level. Per-tenant KMS keys and client-side encryption are out of scope. CORS is permissive (`Access-Control-Allow-Origin: *`). Restrict at the proxy for browser traffic. Tenant offboarding has a first-class erasure path: the admin-plane [bucket purge endpoint](/docs/0.7/operations#tenant-offboarding-bucket-purge) removes a tenant's streams, bucket, and cold objects idempotently, leaving other tenants untouched. It deliberately retains aggregate monotonic usage counters so asynchronous accounting cannot miss committed work; see the operations note for the remaining identifier-erasure limitation. ## Gateway access control (opt-in) A shared or internet-facing deployment can enable OAuth resource-server checks on `ursula gateway`. The feature is off by default; without the flags the gateway keeps its original trusted pass-through behavior. ```bash ursula gateway \ --upstream http://ursula-0:4437 \ --auth-issuer https://issuer.example \ --auth-audience https://streams.example \ --auth-policy /etc/ursula/policy.toml ``` - **Authentication.** `Bearer` credentials are validated as RFC 9068 JWT access tokens: the header must declare `typ: at+jwt` (OIDC ID tokens are rejected), the signature must verify against the issuer's JWKS, and `iss`, `aud`, `sub`, `client_id`, `iat`, `exp`, and `jti` must all be present and valid. The JWKS location comes from `--auth-jwks-url` or RFC 8414 metadata discovery; keys are cached by `kid` and refetched on rotation. - **Tenant boundary.** The bucket is the top-level namespace and logical tenant boundary. The policy file declares each bucket's owners (issuer-qualified subjects) and whether anonymous reads are allowed: ```toml [[bucket]] id = "tenant-a" public_read = true owners = [{ issuer = "https://issuer.example", subject = "user-1" }] ``` - **Concealment.** Unknown buckets, private buckets probed by strangers, and write attempts without ownership all answer the same `404` a missing resource would, so a private stream's existence is not observable. - **Internal paths.** Every `/__ursula/*` path answers `404`, the gateway's own metrics at `/__ursula/gateway/metrics` included. The counters hold no tenant data, but they show traffic volume. `--auth-public-metrics` serves them to every caller, with or without a token, as a gateway without access control does. The gateway then does not check a presented token on that path, so an invalid or expired one gets the counters too. Set the flag only where untrusted clients cannot reach the gateway, or where a proxy in front of it denies `/__ursula/*`. Gateways before 0.7.4 served the metrics to every caller, with or without access control. - **Credential termination.** The gateway strips `Authorization` before forwarding; upstream nodes never see end-user credentials and must remain on a private network. - **Anonymous public reads.** `public_read` grants exactly the read-only actions (read, head, tail, snapshot read) to unauthenticated callers — never writes, deletes, or bucket administration. - **Subscriptions do not outlive their credential.** A live tail is one long-lived request, so a single admission check at connection time would turn a short-lived token into an unbounded read. The gateway ends an SSE subscription at the credential's `exp` with a final `event: credential-expired` frame, so a client can tell expiry from end-of-stream and re-authenticate. Anonymous reads on `public_read` buckets have no credential and so no deadline. Note that this bounds duration, not revocation: a token revoked before its `exp` keeps an open subscription until then. An access-controlled gateway can additionally enable per-tenant admission limits and usage accounting: ```bash ursula gateway ... \ --quota-policy /etc/ursula/quotas.toml \ --usage-log /var/log/ursula/usage.jsonl ``` - **Quotas** (`--quota-policy`): per-bucket request rate (429 with `Retry-After`), concurrent live-read connections, and request body size. Limits are gateway-process-local; a horizontally scaled deployment multiplies effective limits by replica count. Ursula's own `503` backpressure semantics are unchanged. Neither Ursula nor this gateway enforces per-bucket stream-count or retained-bytes limits; enforce them in your control plane from the aggregated `/__ursula/usage` counters. - **Usage** (`--usage-log`): per-tenant request, ingress, and egress byte counters aggregated by `(bucket, principal, action class)` and appended as sequence-numbered JSONL batches on `--usage-flush-secs` intervals. `--usage-chunk-bytes` adds a `chunks` counter alongside them: each append is counted as `ceil(bytes / unit)`, never below one, summed per request. It exists because that sum cannot be recovered afterwards — two appends of 5 KiB and 25 KiB and two of 10 KiB and 20 KiB agree on both request count and byte total while owing four units and three — so a deployment that charges per write unit has to be handed the sum rather than the ingredients. The unit size is a pricing choice and Ursula does not pick one; 10 KB and 25 KB are both in use. A batched append costs proportionally less than the same records sent individually, which is the intended incentive: batching is cheaper to serve. A failing sink delays reporting (batches queue and merge) but never blocks requests or drops counts. Egress is counted from actually streamed bytes, including SSE bodies. Committed-truth counters (append bytes surviving retries, retained bytes) come from Ursula's replicated state and are a separate, complementary ledger. ### Cross-origin reads (opt-in) `public_read` is only nominally public until the origin answers CORS: without it, browser JavaScript cannot read a public stream cross-origin. Allowed origins are deployment policy, so nothing is sent unless you list them. ```bash ursula gateway ... \ --cors-allowed-origin https://app.example.com \ --cors-allowed-origin https://studio.example.com ``` Pass `*` instead to allow any origin. Three properties are deliberate: - **Credentials are never allowed.** Ursula authenticates from an `Authorization` header the caller sets explicitly, which CORS does not treat as credentials, and no cookies are involved. So `*` grants no ambient access — a cross-origin page must still present its own bearer token. - **`Access-Control-Expose-Headers: *`.** A read carries its continuation in response headers (`stream-next-offset`, `stream-cursor`), and a browser cannot see those without exposure — an unexposed client can read one page and never advance. The wildcard is only honoured while credentials stay disallowed, which is the second reason they are. - **Preflight never consults the resource.** `OPTIONS` arrives without `Authorization`, so a per-bucket answer would tell an unauthenticated caller whether a private bucket exists. The preflight reply is identical for every path and is produced before authorization runs, which keeps concealment intact. > [!NOTE] > `EventSource` cannot set request headers, so a browser cannot open an SSE tail on a **private** stream with it. Use `fetch` with a `ReadableStream` and an `Authorization` header. `EventSource` is fine for `public_read` streams. ## Recommended deployment ``` Untrusted internet │ v ┌─────────────────────┐ │ Reverse proxy │ TLS, authn, per-client │ (nginx / Envoy / …) │ rate limiting, CORS └──────────┬──────────┘ │ plain HTTP, private network ┌────────────┼────────────┐ v v v ┌────────┐ ┌────────┐ ┌────────┐ │ Ursula │ │ Ursula │ │ Ursula │ │ node │ │ node │ │ node │ └────────┘ └────────┘ └────────┘ ↕ gRPC h2c on private network (Raft replication) ``` ### Checklist - **Bind to the private interface.** Set `server.listen = "10.0.0.X:4437"` or use a security group / firewall so the listener is unreachable from public networks. - **Terminate TLS at the proxy.** Ursula stays plain HTTP on the internal side. - **Authenticate at the proxy.** Validate the caller (OAuth2, mTLS, signed requests) and reject unauthenticated traffic before it reaches Ursula. - **Block admin paths from public traffic.** Deny `/__ursula/*` on the public listener and allow it only on an internal or ops network. - **Use IAM roles for S3.** Omit static `storage.cold.s3.access_key_id` / `storage.cold.s3.secret_access_key` values and let the AWS SDK credential chain discover credentials. - **Encrypt data volumes.** Apply full-disk encryption to `raft.wal.path`. - **Keep peer traffic private.** Never route gRPC peer traffic across the public internet. ## Reporting vulnerabilities Open a GitHub Security Advisory on [tonbo-io/ursula](https://github.com/tonbo-io/ursula/security/advisories) rather than a public issue. --- # Resumable AI Stream > Stream LLM tokens through a durable timeline so clients can refresh, reconnect, and resume without losing a token. Instead of piping a model's output straight into one HTTP response, the generating worker appends tokens to a stream, and clients replay and tail that stream. A client that reconnects mid-generation replays the answer so far and continues from the same position, without losing or duplicating tokens. The upstream [`chat-aisdk` example](https://github.com/durable-streams/durable-streams/tree/main/examples) uses the same pattern. ## One stream per run Create a text stream per generation, with a TTL so finished runs clean themselves up: ```bash curl -X PUT http://127.0.0.1:4437/agent/run-42 \ -H 'Content-Type: text/plain' \ -H 'Stream-TTL: 86400' ``` ## Producer: append tokens, close at the end The worker that talks to the model appends each chunk. Producer headers make retries safe: a chunk re-sent after a network error is deduplicated instead of appended twice: ```ts const base = "http://127.0.0.1:4437"; let seq = 0; for await (const chunk of modelResponse) { await fetch(`${base}/agent/run-42`, { method: "POST", headers: { "Content-Type": "text/plain", "Producer-Id": "worker-1", "Producer-Epoch": "1", "Producer-Seq": String(seq++), }, body: chunk.text, }); } // Seal the stream: readers get an EOF signal, further appends return 409. await fetch(`${base}/agent/run-42`, { method: "POST", headers: { "Stream-Closed": "true" }, }); ``` If the worker crashes and restarts, bump `Producer-Epoch` and start `Producer-Seq` back at `0` (see [Exactly-once writes](/docs/0.7/concepts/exactly-once-writes)). ## Consumer: replay, then tail `offset=-1` replays everything appended so far, then stays live for new tokens. Reopening the page mid-generation therefore shows the partial answer immediately and continues streaming: ```ts const es = new EventSource(`${base}/agent/run-42?offset=-1&live=sse`); es.addEventListener("data", (e) => { answerEl.textContent += e.data; }); es.addEventListener("control", (e) => { if (JSON.parse(e.data).streamClosed) es.close(); // generation finished }); ``` Or watch it from a terminal: ```bash curl -N 'http://127.0.0.1:4437/agent/run-42?offset=-1&live=sse' ``` The stream is durable and readable by any number of clients, so the same transcript can be tailed from a second device, read after the fact, or consumed by another process. The producer does not need to know about its readers. ## Next - [Chat Room](/docs/0.7/examples/chat-room): the same pattern with JSON records and multiple writers - [Read stream](/docs/0.7/api/read): catch-up, long-poll, and SSE read modes - [Exactly-once writes](/docs/0.7/concepts/exactly-once-writes): producer epoch and sequence rules --- # Chat Room > One JSON stream per room: append messages, replay history from an offset, and tail new messages live. A chat room is a JSON timeline: every chat message is one JSON message in the stream, stored in commit order, and every participant replays history and tails the same stream. This mirrors the chat examples in the [upstream Durable Streams repo](https://github.com/durable-streams/durable-streams/tree/main/examples). ## One stream per room ```bash curl -X PUT http://127.0.0.1:4437/chat/room-7 \ -H 'Content-Type: application/json' ``` Each appended JSON object is stored as one line of NDJSON. Positions in the stream are offsets: opaque tokens that Ursula returns in `Stream-Next-Offset`. Clients store and pass back the offsets Ursula returned and never compute one themselves. ## Send a message ```bash curl -X POST http://127.0.0.1:4437/chat/room-7 \ -H 'Content-Type: application/json' \ --data '{"user":"ada","text":"hello","sent_at":"2026-07-23T09:00:00Z"}' ``` Each append returns the room's new tail in `Stream-Next-Offset`. Appending a top-level JSON array stores one message per element, which is useful for flushing an offline outbox in one request. To make send retries safe against double-posting, add the [exactly-once producer headers](/docs/0.7/concepts/exactly-once-writes) with one `Producer-Id` per device. JSON messages are validated on append and stored exactly as the sender wrote them, minus insignificant whitespace, so member order and number spelling survive. ## Replay history Read the room from its start, or from an offset the client stored earlier: ```bash curl -i 'http://127.0.0.1:4437/chat/room-7?offset=-1' ``` ```json {"user":"ada","text":"hello","sent_at":"2026-07-23T09:00:00Z"} {"user":"lin","text":"hey ada","sent_at":"2026-07-23T09:00:07Z"} ``` The body is NDJSON, one message per line. A response returns at most 8 MiB and can end mid-line: buffer an incomplete trailing line and continue from the response's `Stream-Next-Offset` until a response carries `Stream-Up-To-Date: true`. Loading only the last N messages needs a server-side paging primitive that does not exist yet; until it ships, replay from the start or from a stored offset, or keep a [snapshot](/docs/0.7/concepts/snapshots) of the recent history. ## Tail new messages live `offset=now` skips history and streams only messages committed from now on: ```ts const base = "http://127.0.0.1:4437"; const es = new EventSource(`${base}/chat/room-7?offset=now&live=sse`); let pending = ""; es.addEventListener("data", (e) => { pending += e.data + "\n"; const lines = pending.split("\n"); pending = lines.pop() ?? ""; for (const line of lines) { if (line) renderMessage(JSON.parse(line)); } }); ``` A data event may hold several messages, one per line; SSE `data:` lines are transport framing, not message boundaries. Control events publish `streamNextOffset`. Store it and reconnect with `offset=` to resume without missing or repeating a message. ## Next - [Resumable AI Stream](/docs/0.7/examples/resumable-ai-stream): the single-writer variant for streaming model output - [Offsets](/docs/0.7/concepts/offsets): how resumption works - [Browser Telemetry](/docs/0.7/examples/browser-telemetry): an advanced example with event-time indexing with offset locators --- # Browser Telemetry > Collect replayable browser events in a JSON stream and query them by client event time. Browser telemetry is a natural fit for an `application/json` stream: the browser appends ordinary JSON over HTTP, Ursula stores each event as one message in commit order, and consumers replay or tail events from an offset. The runnable example is in [`examples/browser-telemetry`](https://github.com/tonbo-io/ursula/tree/main/examples/browser-telemetry). ## Data model Keep one timestamp: the time captured by the browser. It remains application data and may arrive out of order after offline buffering or retries. ```json { "captured_at": "2026-07-18T10:30:00.100Z", "type": "network_response", "status": 500 } ``` Ursula does not reorder the stream by `captured_at` and does not invent a second protocol timestamp. Commit order and offsets are the stream's only coordinates: an out-of-order `captured_at` never changes the committed order, the stored bytes or any offset. Event time is therefore not a stream read parameter (there is no `?captured_at=` read); the experimental indexer below answers time-range queries with offset locators. ## Browser collector Point plain `fetch` at a same-origin route that proxies to the Ursula stream. The gateway owns browser authentication and CORS policy. ```js const events = [{ captured_at: new Date().toISOString(), type: "navigation", duration_ms: performance.getEntriesByType("navigation")[0]?.duration, }]; const response = await fetch("/telemetry/events", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(events), keepalive: true, }); console.log(response.headers.get("Stream-Next-Offset")); ``` One array append stores one message per element. A successful response returns the stream's new tail in `Stream-Next-Offset`. ## Replay and live tail Read events from the start of the stream: ```bash curl -i 'http://127.0.0.1:4437/telemetry/events?offset=-1' ``` The body is NDJSON, one event per line. A response returns at most 8 MiB and can end mid-line: buffer any incomplete trailing line and continue from the response's `Stream-Next-Offset`; `Stream-Up-To-Date: true` means the read reached the tail. Offsets are opaque: store and pass back the values Ursula returns, and compare them only with each other. For a live native `EventSource`, add `live=sse`. ## Event-time queries (experimental) Event time is an ordinary secondary index, not another Durable Streams coordinate. The optional `ursula-indexer` worker pool is **experimental** in this release: its API and storage format may change in a minor release. It: 1. reads each registered stream with ordinary offset reads; 2. extracts an event time from every message, skipping and counting messages that have none; 3. writes immutable sorted Parquet parts to S3 and advances a manifest with an ETag compare-and-swap; 4. returns locators — an opaque `offset` plus the message's `len` — that callers resolve back through Ursula. The source stream remains canonical, and the index can be rebuilt from retained history. Equal timestamps are ordered by offset. For Kubernetes, deploy one shared worker pool with its own S3 prefix and workload identity: ```yaml s3: bucket: my-telemetry-index region: us-east-1 indexer: enabled: true replicaCount: 2 s3: prefix: production/telemetry-indexes serviceAccount: annotations: eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/ursula-telemetry-index ``` When an application creates a browser stream, register it without changing the Helm release: ```bash curl -X PUT http://ursula-indexer:4493/v1/indexes/browser-session-42 \ -H 'Content-Type: application/json' \ -d '{"stream_url":"http://ursula-gateway:4437/sessions/browser-session-42", "extract":{"time":["/captured_at"]}}' ``` `extract` names the event time with JSON pointers: `time` is a fallback list, `end` optionally names when an event ended, `each` selects several elements of one message (with `*` matching every member or element), and `unit` is `auto`, `rfc3339`, `s`, `ms`, `us` or `ns`. The older `{"timestamp_field":"captured_at"}` form still works. Registration accepts `application/json` and `application/x-ndjson` streams and starts at the oldest retained event, or at the tail with `"start":"tail"`. Query a time window: ```http GET http://ursula-indexer:4493/v1/indexes/browser-session-42/events?from=2026-07-18T10:00:00Z&until=2026-07-18T11:00:00Z&limit=100 ``` ```json { "source": {"stream_url": "http://ursula-gateway:4437/sessions/browser-session-42", "incarnation": "1784370000000"}, "coverage": {"from": "00000000000000000000", "floor": "00000000000000000000", "through": "00000000000000052114", "durable": "00000000000000052114", "complete": true, "trimmed_bytes": 0}, "skipped": {"missing": 0, "invalid": 0, "unparseable": 0, "oversize": 0}, "entries": [ {"t_ms": 1784370600100, "t_end_ms": 1784370600100, "offset": "00000000000000004211", "len": 97}, {"t_ms": 1784370600100, "t_end_ms": 1784370600100, "offset": "00000000000000006630", "len": 88} ], "next": "8000019f74c698a400000000000019e6" } ``` Fetch an event with its locator. A read returns at most 8 MiB, so keep reading from each response's `Stream-Next-Offset` until `len` bytes have arrived; never compute an offset yourself. Locators are valid for `source.incarnation`; if the stream's `Stream-Incarnation` (HEAD) differs, the stream was recreated and the locators are stale: ```js async function fetchEvent(streamUrl, { offset, len }) { const chunks = []; let received = 0; while (received < len) { const response = await fetch(`${streamUrl}?offset=${offset}&max_bytes=${len - received}`); if (!response.ok) throw new Error(`event is no longer readable: ${response.status}`); const chunk = new Uint8Array(await response.arrayBuffer()); if (chunk.length === 0) throw new Error("event is no longer readable"); chunks.push(chunk); received += chunk.length; offset = response.headers.get("Stream-Next-Offset"); } const bytes = new Uint8Array(received); let at = 0; for (const chunk of chunks) { bytes.set(chunk, at); at += chunk.length; } return JSON.parse(new TextDecoder().decode(bytes)); } ``` Fetch the next page with the first response's `next` and `coverage.through`, so events indexed in the meantime do not appear halfway through one paginated answer: ```http GET /v1/indexes/browser-session-42/events?from=2026-07-18T10:00:00Z&until=2026-07-18T11:00:00Z&limit=100&after=8000019f74c698a400000000000019e6&through=00000000000000052114 ``` `match=overlap` returns events whose `[t_ms, t_end_ms]` span intersects the window instead of events that start inside it. `GET /v1/indexes/{id}/status` reports the same `source`, `coverage` and `skipped` fields. Bad data never stops indexing: messages without a usable time are counted under `skipped`. When retention trims events before they were indexed, the index follows the stream's retained position and reports `"complete": false`. When the stream is deleted and created again, the index restarts on the new stream and `/status` reports `restarted_from_incarnation`. Indexing stops only when two workers disagree about the same events; the existing index stays queryable, and `POST /v1/indexes/{id}/status/resume` clears the block after repair. S3 stores immutable content-addressed Parquet parts and versioned manifests. Parts are split into UTC-day event-time partitions, and bounded size-tiered compaction merges a fixed number of parts within one day. Manifest-aware garbage collection keeps a configurable number of recent generations and an in-flight grace window. The local cache is bounded and disposable, so a replacement pod recovers from S3 without a persistent volume. ## OpenTelemetry (OTLP-JSON) The same indexer serves OpenTelemetry data exported as OTLP-JSON. Create one `application/json` stream per service, signal and day, and point the stock collector's `otlphttp` exporter at it: ```yaml exporters: otlphttp/ursula: traces_endpoint: http://ursula-gateway:4437/otel/traces-checkout-20261003 encoding: json compression: none processors: batch: send_batch_max_size: 2048 ``` Ursula does not decompress request bodies, so keep `compression: none`, and keep batches well under the 32 MiB request limit. Each export request is stored as one message. Register the stream with an extractor that walks every span of a batch: ```bash curl -X PUT http://ursula-indexer:4493/v1/indexes/traces-checkout-20261003 \ -H 'Content-Type: application/json' \ -d '{"stream_url":"http://ursula-gateway:4437/otel/traces-checkout-20261003", "extract":{"each":"/resourceSpans/*/scopeSpans/*/spans/*", "time":["/startTimeUnixNano"],"end":["/endTimeUnixNano"],"unit":"ns"}}' ``` Each batch becomes one entry spanning its earliest span start to its latest span end; query it with `match=overlap`. For logs, use `"each":"/resourceLogs/*/scopeLogs/*/logRecords/*"` and `"time":["/timeUnixNano","/observedTimeUnixNano"]`: OTLP leaves an unset `timeUnixNano` at `0`, which counts as missing, so the observed time is used instead. --- # SQLite on Ursula `@tonbo/sqlite-ursula` gives a Node.js application a SQLite database that an Ursula stream replicates. `open` needs `@tonbo/sqlite-ursula` 0.7.5 or later. With 0.7.4 and earlier, [attach the file yourself](#attach-a-file-yourself). Start a single node in a terminal of its own. It keeps nothing across restarts. ```bash docker run --rm -p 4437:4437 ghcr.io/tonbo-io/ursula:0.7.6 ``` Install the package, which needs Node.js 22.19 or later, and run: ```bash npm install @tonbo/sqlite-ursula ``` ```ts import { open } from "@tonbo/sqlite-ursula"; const db = open("http://127.0.0.1:4437/demo/notes"); db.exec("CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)"); db.prepare("INSERT INTO notes(body) VALUES (?)").run("hello"); // returns once the stream holds it console.log(db.prepare("SELECT id, body FROM notes").all()); db.close(); ``` `open` loads the `sqlite-ursula-vfs` extension into `node:sqlite`, catches a local copy of the database up from the stream, claims the stream for this process and returns the database. Every commit is appended to the stream before it reaches the local file, so any host can rebuild the database from the stream: the same code on another host opens the same database, and fences this one. The database is a `node:sqlite` `DatabaseSync`, so libraries that take one take it. The local copy is a cache, kept in a file named after the stream URL (see [Local files](#local-files)). The local copy outlives the node. After you restart the node, its stream is gone, and `open` refuses to put an empty stream over a local copy that holds data (`not_found`). Delete `demo-notes-*` in `~/Library/Caches/sqlite-ursula` (`~/.cache/sqlite-ursula` on Linux), or open another stream. ## Errors From 0.7.5, a failure of the extension throws an `UrsulaError`. Its `action` says what to do, its `kind` names what failed, and its `cause` is the error the failure surfaced as, usually SQLite's `disk I/O error` or `SQL logic error`. Every other error, such as a constraint violation, a syntax error or a write to a read-only database, is `node:sqlite`'s own, unchanged. `open` throws these errors, and so do the database's `exec`, `prepare` and `applyChangeset`, its statements' `run`, `get`, `all` and `iterate`, and `attach` and `status`. | `action` | What it means for you | | --- | --- | | `retry` | Nothing reached the stream, and the database stays usable. Run it again later on the same database: the statement, or the whole transaction if it was one, since SQLite rolled the transaction back. No new `open` is needed. When `open` itself failed, call `open` again later. A layer in front of the stream that refuses writes, such as a quota service, or an endpoint that cannot be reached, fails this way. A commit that went out and got no answer is `reattach` instead. | | `reattach` | The local file may no longer match the stream, for example after a commit whose outcome is unknown. Close the database and call `open` again: the new attach replays the stream, and the database then holds what the stream holds. The database's own `close()` and `open()` methods attach nothing. | | `stop` | Another owner took the stream over. Stop using the database, and never reopen it in a loop: every `open` takes the stream back from the other owner, which only a deliberate takeover does. Exit, but only where nothing starts this process on the stream again (see [Rules](#rules)). | | `fix` | The request cannot succeed as made: a stream URL the server refuses, a missing stream, a database the process has open already, a directory that refuses the local files, a transaction too large for one append. The message says what to change. | ```ts import { UrsulaError } from "@tonbo/sqlite-ursula"; try { db.prepare("INSERT INTO notes(body) VALUES (?)").run("hello"); } catch (error) { if (!(error instanceof UrsulaError)) throw error; // SQLite's own error, such as a constraint violation if (error.action === "stop") process.exit(1); // another owner took over, see Rules for what may restart us throw error; // retry: try again later. reattach: close and open again. fix: change the setup. } ``` Branch on `action`. The classes group the failures by what happened: | Class | `action` | `kind` | | --- | --- | --- | | `UrsulaWriteRefusedError` | `retry` | `unauthorized`, `forbidden`, `rate_limited`, `not_applied`, `unreachable`, `refused`, `unavailable` | | `UrsulaPaymentRequiredError`, a subclass | `retry` | `payment_required` | | `UrsulaWriteRefusedError` | `fix` | `too_large`: the same transaction never fits in one append, so split it | | `UrsulaReattachError` | `reattach` | `outcome_unknown`, `local_damage`, `local_state`, `internal` | | `UrsulaFencedError` | `stop` | `fenced` | | `UrsulaReplicationError` | `fix` | `unsupported`, `stream_state`, `local_setup`, `invalid_url` (a commit that met a redirect), any kind whose action is `fix`: a commit poisoned the file for a cause to fix before you open it again | | `UrsulaSetupError` | `fix` | `invalid_argument`, `invalid_url`, `invalid_name`, `not_found`, `in_use`, `unreplicated_file`, `other_stream`, `unsupported`, `stream_state`, `not_attached`, `local_setup` | `UrsulaWriteRefusedError` means that nothing reached the stream. For a refused commit its `kind` and `reason` are `status(file)`'s `refused_kind` and `refused_reason` (see [Writes refused before they reach the stream](#writes-refused-before-they-reach-the-stream)). For a failed attach its `kind` is the extension's, and its message says what refused it. Only `attach` itself gets a `402`'s own code, so `status` of a path whose attach a `402` refused throws `UrsulaWriteRefusedError` of kind `refused`, not `UrsulaPaymentRequiredError`. `UrsulaFencedError` and `UrsulaReattachError` are subclasses of `UrsulaReplicationError`, the class of a commit the stream did not take, whose `fenced` is `true` for a fence. A fenced database's reads fail too, with a message that starts with `read refused`. The kinds are those of the extension, which the [design doc](https://github.com/tonbo-io/ursula/blob/main/docs/architecture/sqlite-vfs.md#errors) lists, except that where the extension's kind of a refused commit is `refused`, the error's `kind` names what refused it. The message of a failed attach is the extension's, `ursula_attach: : `, which says what failed and how to fix it. The message of a failed statement names the failure and what to do, such as `commit refused with 402 Payment Required in front of the stream: quota exhausted. Nothing of it reached the stream, and the database stays usable: run it again later, the whole transaction if it was one.` When `cause` is SQLite's error, its `code`, `errcode` and `errstr` are copied to the `UrsulaError`, so code that reads them keeps working. SQLite rolls a transaction back when the extension fails one of its statements or its COMMIT. A transaction helper, yours or a library's such as drizzle's, then runs a ROLLBACK, which would fail with "cannot rollback - no transaction is active" and replace the `UrsulaError`. The database keeps the `UrsulaError` instead: such a ROLLBACK as a prepared statement succeeds, since nothing is left to roll back, and through `exec`, or as a ROLLBACK TO or RELEASE of a savepoint, it throws the `UrsulaError` again. A library that wraps errors, as drizzle does, keeps the `UrsulaError` as its error's `cause`. Classification covers the file `open` attached. Other paths throw `node:sqlite`'s errors as they are: a tag store, a session, `backup()`, and a second replicated file used through SQL `ATTACH DATABASE`. A connection you open yourself (see [Attach a file yourself](#attach-a-file-yourself)) does too, and so does one made through `db.constructor`. A failed commit or read then names nothing by itself: `status(file).error` of the file that failed says what failed (`kind`) and what to do (`action`) while writes fail (`db.location()` is the file `open` attached). ## Rules - One owner per stream. `open` (and `attach`) claims the stream for the process with a new producer epoch, which fences every earlier owner, on this host or another. The fenced owner's next commit fails with `UrsulaFencedError`, and from then on so do its reads. - To move a database to another host or process, open it there. On the same host, a second process opens the same local file named after the URL, so it fails with `in_use` until the old one exits: stop the old process first, or give the new one its own `dir` or `path`, which rebuilds the database from the stream and fences the old one. The new owner's claim takes the stream over, and the old owner must stop, as the `stop` action says. Opening it again takes the stream back, so only a deliberate takeover does that, and nothing may start the old owner on the stream again while the new one runs. A scheduler that restarts an exited process in place does exactly that: the restarted process's `open` takes the stream back, and the two take turns fencing each other. Under Kubernetes, which restarts an exited container in place, stop the old pod before the new one opens the stream (the `Recreate` strategy, or a StatefulSet with one replica), or keep the fenced process running and unready until it is terminated. A rolling deploy that starts the new process before it stops the old one hands the stream over only when the old process stays down. - With `open`, open a stream once per process and share the database. Opening it again while the database is open fails with `in_use`, and opening it at another `path` takes the stream over from the first database. Close it first to open the stream again, as the `reattach` action does. - One process owns a database file at a time (a lock file next to it enforces this on the host). Another process that opens the same file fails with `in_use` until the first exits. Use as many connections inside that process as you like (see [Attach a file yourself](#attach-a-file-yourself)). - WAL mode only, 4 KiB pages (SQLite's default). Attach leaves the file in WAL mode, so you set nothing: do not change `journal_mode` or the page size. `locking_mode=EXCLUSIVE` is not supported. - Treat the local file as a cache: do not copy it elsewhere and attach it to another stream. A commit is durable once Ursula acknowledges it. Commits do not fsync the local files (only the db file, once per WAL wrap), so `PRAGMA synchronous` does not matter. - After a reboot, a power loss, or on another host, attach discards the local files and rebuilds them from the latest snapshot and the stream's tail (a crashed process on the same boot resumes from its local files). A volume restored from a disk snapshot without a reboot is checked: attach uses it only if its WAL still holds what the sidecar `-ursula` records, and rebuilds otherwise. Keep the file on a local disk, not a network filesystem, and do not copy it with `cp` or `rsync` while the process runs. Delete the file to force a rebuild. - A commit whose append gets no answer is retried with the same producer sequence for up to 30 s (`URSULA_VFS_RETRY_MS`), then fails, and the file must be attached again. - From 0.7.3 the extension keeps a connection for 90 s after its last request (`URSULA_VFS_IDLE_MS`, in milliseconds, 0 turns reuse off), so commits up to that far apart reuse it instead of opening a new TCP and TLS connection. A request that gets no answer (its connection was reset or closed, or it timed out) is sent again on a new connection: a commit with the same producer sequence, and a read within `URSULA_VFS_RETRY_MS`. A refused connection fails a read at once. On macOS a reset can show as `Invalid argument (os error 22)`: from 0.7.4 that counts as no answer too, so a read that meets it is sent again, as on Linux. A NAT, a load balancer or a firewall on the way that drops idle connections sooner than 90 s without telling either end makes the first request after such a pause wait out its timeout (10 s) before it is sent again. Behind such a device, set `URSULA_VFS_IDLE_MS` below its idle limit. - From 0.7.3 the extension never follows a redirect. A `307` (a gateway before 0.7.3 passing on a redirect to the stream's leader, which moved, or a node that the URL names and that does not lead the stream's group) is sent again to the same URL within `URSULA_VFS_RETRY_MS`, like a `503`. Any other redirect fails the request at once with the redirect's `Location` in the error: a commit then fails, and the file must be attached again. A URL that names one node of a multi-node cluster works only while that node leads the stream's group. A URL that a proxy redirects (from `http://` to `https://`, say, usually with a `308`) fails, so use the address the redirect points to. - A transaction must fit one request: up to 32 MiB of changed pages after compression by default (the server's request body limit), set by `URSULA_VFS_MAX_FRAME_BYTES` in bytes. That is about 8,000 pages of incompressible data, such as embeddings, media or encrypted bytes, and about three times as many of typical text. A larger transaction is never sent. It fails alone with `SQLITE_IOERR_AUTH` and `refused_kind` `too_large` (an `UrsulaWriteRefusedError` whose `action` is `fix`), and the file stays usable. Split bulk imports into smaller transactions. Statements that rewrite a whole table or index are one transaction too: `CREATE INDEX` on a large table, `REINDEX`, an `ALTER TABLE` that rebuilds the table, and `VACUUM`, which rewrites every page. On a database whose compressed size is above the limit, `VACUUM` fails the same way. Delete rows before it, so that what it writes compresses below the limit, or copy the rows into a new database in transactions below the limit. - Behind a gateway or proxy with a lower body limit, set `URSULA_VFS_MAX_FRAME_BYTES` at or below it. A larger commit sent there usually meets a closed connection rather than a readable `413` ([#540](https://github.com/tonbo-io/ursula/issues/540)), and then poisons the file once `URSULA_VFS_RETRY_MS` runs out. `URSULA_VFS_MAX_FRAME_BYTES` takes 64 KiB to 32 MiB: a value outside is taken to the nearer bound, and one that is not a number of bytes is ignored, both logged once as `setting_adjusted`. - A file that has been attached (it has a sidecar `-ursula`) opens in a new process only after its attach there succeeds. From 0.7.5 a file whose attach failed in the process (the server is unreachable, or refused the stream's name, for example) opens only after an attach of it succeeds, with or without a sidecar. Opening it otherwise fails, so no commit can bypass the stream: SQLite reports "unable to open database file", and the log and `status(file)` name the failed attach's reason. An attach refused because of the file itself before it changed anything (`in_use`, `unreplicated_file`, `other_stream`) is no failed attach: the file opens as before. A failed attach may already have changed the local files, as any attach does: discarded the ones it did not trust, or folded their WAL into the file. One that fails before it wrote anything into a new file removes the sidecar it created, which never touches the file, its `-wal` or `-shm`. A file it replayed pages into keeps its sidecar: the next attach resumes it when the sidecar names the file, and rebuilds it otherwise. - From 0.7.5 attach refuses (`in_use`), changing nothing, while the process has the file, its `-wal`, `-shm` or `-journal` open outside the extension, read-only or not: a connection opened before the extension was loaded, or one opened through another VFS, such as `?vfs=unix`. Its commits would never reach the stream. While such a connection is open, SQLite also keeps the files of closed connections open. Close every such connection, then attach. The check reads the process's open files where it can: on Linux with `/proc` mounted, and on macOS. Without that, on other platforms, or when the file's directory cannot be resolved, attach does not check, and it never finds a file opened through another hard link or mount of it. A database that was never attached and holds data is refused, never replicated over, and opens as before: copy its rows into a newly attached file, in the same process if you like (with `ATTACH`, for example), or move it and its `-wal` aside to attach the path. - Never write an attached file outside the extension. A write after the attach, through a connection that bypasses the extension or from a process without it, is not detected, as in 0.7.4: it never reaches the stream, and the owner's later commits and snapshots carry pages built on it, so copies rebuilt from the stream can diverge from the file or be malformed. - A stream deleted and recreated at the same path is a new stream: attach never resumes the local file on it, and rebuilds it from the new stream, whatever its length. When the stream of a file that holds data is missing (deleted by mistake, expired, or not carried over by a fresh install), attach refuses and keeps the file, which may be the only copy left. Delete the file to start over from an empty stream, or attach a new file under another stream URL. Neither the file nor a copy of it attaches under another URL. To salvage its data, copy the file and its `-wal` to a new name before deleting anything. The copy has no sidecar, so it opens as a plain SQLite file: read its rows from there into a newly attached database. An owner of the deleted stream is normally fenced at its next commit, but stop every owner before deleting a stream you will recreate: a snapshot it is publishing, or an attach racing the recreate, can still put its data into the new stream (see the design doc). - Within a minor (0.7.x), extension versions take over each other's streams, so upgrade owners one at a time: the new owner's attach fences the old one. Across minors, follow the release notes. A minor that changes the extension's `Producer-Id` cannot fence owners of the other version, so both would commit and could corrupt the database: stop every owner of a stream before attaching it with the new version (and the reverse on a downgrade). A frame or snapshot of another minor's format stops attach with an error that names it. - An owner idle for 7 days loses its producer state on the server. Its next commit takes the stream back only if nobody else wrote meanwhile. - Every commit carries a `Stream-Seq` above all earlier ones. Do not append to the stream yourself: a write with a higher `Stream-Seq` fences the owner at its next commit, and one without breaks every rebuild that replays it, until the owner publishes a snapshot past it. - `URSULA_VFS_SNAPSHOT_MIN_BYTES` sets the smallest log between snapshots (default 8 MiB). ## How it works `sqlite-ursula-vfs` is a SQLite loadable extension that replicates a database file to an Ursula stream. Your application keeps using SQLite unchanged, for example `node:sqlite` or Pi Durable's official `SqliteStorage`: every commit is appended to the stream before it reaches the local file, and any host can rebuild the database from the stream. The extension lives in [`clients/sqlite-vfs`](https://github.com/tonbo-io/ursula/tree/main/clients/sqlite-vfs), and the TypeScript helpers in [`clients/sqlite-ursula`](https://github.com/tonbo-io/ursula/tree/main/clients/sqlite-ursula), published to npm as `@tonbo/sqlite-ursula` with the extension prebuilt. The package has no runtime dependencies, and its Pi Durable adapter is a separate entry, [`@tonbo/sqlite-ursula/pi`](#pi-durable). - One database is one binary stream (`application/octet-stream`). A commit is one append carrying the transaction's final page images, zstd-compressed. - The append happens at SQLite's commit point. `COMMIT` returns only after Ursula acknowledged it. If the append is rejected, SQLite rolls the transaction back and nothing of it stays local. - Attaching a file claims the stream with a new producer epoch. Every earlier owner is fenced: its next commit fails, and from then on so do its reads. - The owner publishes a [snapshot](/docs/0.7/concepts/snapshots) of the database whenever the log since the last one outgrows the database (8 MiB at least), reads it back, and then advances the stream's retention to the snapshot before it. A new host starts from the latest snapshot and replays only the tail. ## Local files Requires 0.7.5 or later of `@tonbo/sqlite-ursula`. `open(streamUrl, options)` takes `node:sqlite`'s own options (`timeout`, `readBigInts` and the rest), which it checks before it attaches anything, so a wrong one claims nothing. It takes three of its own: - `path`: the local file to keep the database in, a plain file path. A relative one is made absolute when `open` runs, so a later `process.chdir` changes nothing. `:memory:`, `""` and `file:` URIs are refused (`invalid_argument`) before anything is attached: SQLite would open a temporary database or read a URI, not the attached file. - `dir`: the directory of a file named after the stream URL, used when `path` is not given. `open` creates it (mode `0700`) when it is missing. By default it is `sqlite-ursula` in your cache directory: `$XDG_CACHE_HOME` when that is set to an absolute path, `~/Library/Caches` on macOS and `~/.cache` elsewhere, in the home directory that `HOME` names, or the user database when `HOME` is not an absolute path. Without either, `open` needs `dir` or `path`. - `readOnly`: attach the file [read-only](#read-only-attach) and open it read-only. Without `path`, on a file of its own (see below). The stream URL names the stream alone. A URL with a query or a fragment is refused (`invalid_url`) before any request is sent, since the extension adds its own query to the stream URL. The file named after the URL is `