From 4b3edf107457d87935e7246c22d375a833b27798 Mon Sep 17 00:00:00 2001
From: livesite
Date: Thu, 27 Aug 2026 21:57:49 +0800
Subject: [PATCH] release: add WebSocket TTL live subscriptions for 0.10.0
---
Cargo.lock | 96 +-
Cargo.toml | 6 +-
README.md | 215 +++-
console-ui/package.json | 2 +-
console-ui/src/layout/AppShell.tsx | 2 +-
crates/discovery/Cargo.toml | 1 +
crates/discovery/src/main.rs | 26 +-
crates/discovery/src/server.rs | 31 +-
crates/operator/src/controller/mod.rs | 26 +
crates/operator/src/controller/preflight.rs | 32 +-
crates/operator/src/controller/status.rs | 1 +
crates/operator/src/crd.rs | 38 +
crates/operator/src/lib.rs | 2 +-
crates/operator/src/resources.rs | 49 +-
crates/queue/src/broker.rs | 23 +-
crates/queue/src/broker/expiration.rs | 4 +
crates/queue/src/broker_tests.rs | 93 ++
crates/queue/src/topic.rs | 11 +
crates/server/src/config.rs | 51 +
crates/server/src/config/environment.rs | 21 +
crates/server/src/config/tests.rs | 13 +
crates/server/src/http.rs | 39 +-
crates/server/src/http/kodo_compat.rs | 7 +
crates/server/src/http/websocket.rs | 1061 +++++++++++++++++
crates/server/src/main.rs | 9 +
crates/server/src/metrics.rs | 44 +
crates/server/src/tcp.rs | 8 +-
crates/server/src/tcp/ephemeral.rs | 31 +-
deploy/helm/rustqueue/Chart.yaml | 4 +-
.../crds/rustqueue.io_rustqueues.yaml | 17 +
deploy/helm/rustqueue/templates/NOTES.txt | 7 +
deploy/helm/rustqueue/templates/queue.yaml | 7 +
deploy/helm/rustqueue/values.yaml | 10 +-
docs/architecture/share-nothing-v7.md | 126 +-
docs/operations/kubernetes.md | 81 +-
docs/releases/v0.10.0.md | 204 ++++
fuzz/Cargo.lock | 122 +-
rustqueue.example.toml | 9 +
scripts/acceptance-multi-broker-k8s.sh | 4 +-
site/index.html | 6 +-
40 files changed, 2431 insertions(+), 108 deletions(-)
create mode 100644 crates/server/src/http/websocket.rs
create mode 100644 docs/releases/v0.10.0.md
diff --git a/Cargo.lock b/Cargo.lock
index 022585e..26974c9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -245,6 +245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
+ "base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -263,8 +264,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
+ "sha1",
"sync_wrapper",
"tokio",
+ "tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -2025,10 +2028,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
- "rand_chacha",
+ "rand_chacha 0.3.1",
"rand_core 0.6.4",
]
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand"
version = "0.10.2"
@@ -2050,6 +2063,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -2059,6 +2082,15 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -2283,7 +2315,7 @@ dependencies = [
[[package]]
name = "rustqueue-bench"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"clap",
@@ -2295,7 +2327,7 @@ dependencies = [
[[package]]
name = "rustqueue-console"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"axum",
@@ -2323,7 +2355,7 @@ dependencies = [
[[package]]
name = "rustqueue-discovery"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"axum",
@@ -2340,13 +2372,14 @@ dependencies = [
"sha2",
"tokio",
"tower",
+ "tower-http",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "rustqueue-operator"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"axum",
@@ -2373,7 +2406,7 @@ dependencies = [
[[package]]
name = "rustqueue-protocol"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"bytes",
"serde",
@@ -2383,7 +2416,7 @@ dependencies = [
[[package]]
name = "rustqueue-proxy"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"axum",
@@ -2404,7 +2437,7 @@ dependencies = [
[[package]]
name = "rustqueue-queue"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"bytes",
@@ -2426,7 +2459,7 @@ dependencies = [
[[package]]
name = "rustqueue-server"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"async-compression",
@@ -2462,7 +2495,7 @@ dependencies = [
[[package]]
name = "rustqueue-storage"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"crc32c",
@@ -2475,14 +2508,14 @@ dependencies = [
[[package]]
name = "rustqueue-telemetry"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"serde",
]
[[package]]
name = "rustqueuectl"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"clap",
@@ -2713,6 +2746,17 @@ dependencies = [
"unsafe-libyaml",
]
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2980,6 +3024,18 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -3174,6 +3230,22 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.5",
+ "sha1",
+ "thiserror",
+]
+
[[package]]
name = "typenum"
version = "1.20.1"
diff --git a/Cargo.toml b/Cargo.toml
index 65cb892..147851a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,7 +16,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
-version = "0.9.0"
+version = "0.10.0"
edition = "2021"
license = "Apache-2.0"
rust-version = "1.88"
@@ -25,7 +25,7 @@ rust-version = "1.88"
anyhow = "1.0.95"
async-compression = { version = "0.4.42", features = ["tokio", "deflate"] }
flate2 = "1.1"
-axum = "0.8.1"
+axum = { version = "0.8.1", features = ["ws"] }
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
bytes = { version = "1.9.0", features = ["serde"] }
chrono = { version = "0.4.39", default-features = false, features = ["clock", "serde"] }
@@ -51,7 +51,7 @@ tokio-rustls = "0.26.1"
tokio-snappy = "0.2.0"
toml = "0.8.19"
tower = "0.5.2"
-tower-http = { version = "0.6.2", features = ["fs", "timeout", "trace"] }
+tower-http = { version = "0.6.2", features = ["cors", "fs", "timeout", "trace"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
rustqueue-telemetry = { path = "crates/telemetry" }
diff --git a/README.md b/README.md
index c4faa7d..1865bf0 100644
--- a/README.md
+++ b/README.md
@@ -11,14 +11,14 @@
[NSQ performance boundaries](docs/architecture/nsq-performance.md) ·
[Kubernetes operations](docs/operations/kubernetes.md) ·
[Console operations](docs/operations/console.md) ·
-[v0.9.0 release](https://github.com/SamuelSupe/rustqueue/releases/tag/v0.9.0)
+[v0.10.0 release](https://github.com/SamuelSupe/rustqueue/releases/tag/v0.10.0)
-RustQueue 0.9.0 is a Kubernetes-native, NSQ V2-compatible message queue for
+RustQueue 0.10.0 is a Kubernetes-native, NSQ V2-compatible message queue for
trusted internal networks. It is written in Rust and uses a deliberately
simple share-nothing model: each Broker owns one durable RWO PVC, while
Kubernetes provides scheduling, rollout and discovery.
-> Current release: [v0.9.0](https://github.com/SamuelSupe/rustqueue/releases/tag/v0.9.0).
+> Current release: [v0.10.0](https://github.com/SamuelSupe/rustqueue/releases/tag/v0.10.0).
> RustQueue is a production candidate for workloads that accept single-PVC
> durability and at-least-once delivery. It does not replicate messages between
> Brokers and is not an HA replacement for a replicated log.
@@ -32,6 +32,7 @@ The complete architecture and reliability contract is documented in
| --- | --- |
| Durability | Default `PUB`/`MPUB`/`DPUB` return after local segment `fsync`; opt-in `write_ack` and `nsq_relaxed` return after append with explicit crash-loss windows; `FIN`/`REQ` use a durable channel WAL |
| Delivery | At least once; a restart may redeliver a message without a durable `FIN` |
+| Live subscriptions | Optional WebSocket `rustqueue.live.v1` stream for existing `TTL_DISCARD` Topics; best effort, no replay or ACK |
| Compatibility | NSQ V2 core commands, lookup, standard Stats fields, TLS/mTLS, AUTH, Snappy, Deflate, fan-out and ephemeral channels |
| Kodo | Default-off compatibility profile: stable publish Gateways from `/nodes`, real Broker owners from `/lookup`, and no upstream Kodo change |
| Message size | Conservative 20 MiB default; stable 100 MiB protocol/storage ceiling; Kodo profile validates exactly 104,857,500 bytes |
@@ -44,54 +45,43 @@ messages stored on that Broker are lost. Configure disk pressure protection,
monitor the exported metrics, and choose PVC/storage failure policies that fit
your workload before deploying to production.
-## What's new in 0.9.0
-
-- **Topic-level TTL discard.** Managed Topics can choose `TTL_DISCARD` with a
- positive whole-second `messageTtlSeconds`; `RELIABLE` remains the default and
- requires no TTL. The TTL starts at the Broker write timestamp and is an
- absolute lifetime.
-- **One final expiration boundary.** Expiry applies to unrouted, deferred,
- requeued and in-flight messages. It evicts the message from every Channel,
- never writes a DLQ entry, and lets `TTL_DISCARD` retry normally until the TTL
- rather than ending through retention or maximum-attempt DLQ rules.
-- **Safe online policy changes.** Topic creation can set the policy, and
- `/v1/manage/topics/configure` supports the `CONFIGURE` operation for existing
- Topics. Enabling or shortening TTL previews possible backlog loss and
- requires the exact Topic name; visible multi-owner migrations block policy
- changes. Extending or disabling TTL cannot revive an already expired message.
-- **Deadline-bound delivery.** Reservation and socket handoff recheck the
- absolute deadline. If a frame cannot finish before expiry, the consumer
- connection closes instead of leaving a partially parseable NSQ frame; a
- complete frame that is not `FIN`ed still expires at the same boundary.
-- **Wire and storage compatibility.** `PUB`, `MPUB` and `DPUB` keep their
- existing `publish_ack_mode` acknowledgement behavior, and NSQ TCP/HTTP
- publishing remains unchanged. Existing and auto-created Topics stay
- `RELIABLE`; old manifests default to that mode, and disk format v7 needs no
- migration.
-
-See the [v0.9.0 release notes](https://github.com/SamuelSupe/rustqueue/releases/tag/v0.9.0)
-and [NSQ performance boundaries](docs/architecture/nsq-performance.md) for
-the contract and benchmark interpretation.
-
-## Download 0.9.0
+## What's new in 0.10.0
+
+- **TTL live WebSocket subscriptions.** The default-off WebSocket endpoint
+ streams only existing `TTL_DISCARD` Topics. It is a best-effort feed with no
+ replay, reconnect resume or client ACK; consumers use Discovery `/lookup`
+ and connect to every returned Topic owner.
+- **Bounded live delivery.** Each connection uses one ephemeral Channel and at
+ most one reservation. Frame memory, total connections and per-Topic
+ connections are bounded; a slow or expired write closes the connection and
+ releases the reservation.
+- **Existing protocol compatibility.** Topic TTL discard remains the v0.9.0
+ capability, while NSQ TCP/HTTP publishing, ACK modes, storage format v7 and
+ existing `RELIABLE` behavior remain unchanged.
+
+See the [v0.10.0 release notes](docs/releases/v0.10.0.md) and [NSQ performance
+boundaries](docs/architecture/nsq-performance.md) for the contract and
+benchmark interpretation.
+
+## Download 0.10.0
Every release contains native Linux binaries, the Console UI, source, the Helm
Chart and a checksum manifest:
| Asset | Contents |
| --- | --- |
-| `rustqueue-0.9.0-linux-x86_64.tar.gz` | Linux x86_64 binaries, Console UI and example configuration |
-| `rustqueue-0.9.0-linux-aarch64.tar.gz` | Linux ARM64 binaries, Console UI and example configuration |
-| `rustqueue-0.9.0-source.tar.gz` | Source archive for the tagged commit |
-| `rustqueue-0.9.0.tgz` | Helm Chart |
-| `SHA256SUMS-0.9.0` | SHA-256 checksums for every downloadable artifact |
+| `rustqueue-0.10.0-linux-x86_64.tar.gz` | Linux x86_64 binaries, Console UI and example configuration |
+| `rustqueue-0.10.0-linux-aarch64.tar.gz` | Linux ARM64 binaries, Console UI and example configuration |
+| `rustqueue-0.10.0-source.tar.gz` | Source archive for the tagged commit |
+| `rustqueue-0.10.0.tgz` | Helm Chart |
+| `SHA256SUMS-0.10.0` | SHA-256 checksums for every downloadable artifact |
```sh
arch="$(uname -m)"
-curl -LO "https://github.com/SamuelSupe/rustqueue/releases/download/v0.9.0/rustqueue-0.9.0-linux-${arch}.tar.gz"
-curl -LO "https://github.com/SamuelSupe/rustqueue/releases/download/v0.9.0/SHA256SUMS-0.9.0"
-sha256sum --check --ignore-missing SHA256SUMS-0.9.0
-tar -xzf "rustqueue-0.9.0-linux-${arch}.tar.gz"
+curl -LO "https://github.com/SamuelSupe/rustqueue/releases/download/v0.10.0/rustqueue-0.10.0-linux-${arch}.tar.gz"
+curl -LO "https://github.com/SamuelSupe/rustqueue/releases/download/v0.10.0/SHA256SUMS-0.10.0"
+sha256sum --check --ignore-missing SHA256SUMS-0.10.0
+tar -xzf "rustqueue-0.10.0-linux-${arch}.tar.gz"
```
## Architecture
@@ -182,7 +172,7 @@ operator -> eligible nodes -> StatefulSet ordinal + retained RWO PVC
sent is returned as ambiguous and is never retried automatically.
- `rustqueue-operator`: creates the StatefulSet, retained PVCs, discovery,
proxy, RBAC, disruption budgets, PVC expansion and drain-aware one-at-a-time
- rolling updates. The 0.9 Kodo profile adds atomic Discovery cutover,
+ rolling updates. The Kodo compatibility profile adds atomic Discovery cutover,
producer-restart fencing and fail-closed decommissioning.
- `rustqueue-console`: Kubernetes and broker observability backend serving the
bilingual Carbon UI, with default-off native Topic/Channel management.
@@ -193,6 +183,130 @@ The broker implements `IDENTIFY`, `AUTH`, `SUB`, `PUB`, `MPUB`, `DPUB`, `RDY`,
`FIN`, `REQ`, `TOUCH`, `NOP`, and `CLS`, including TLS/mTLS, Snappy, Deflate,
output buffering, sampling, fan-out and ephemeral channels.
+## WebSocket TTL live subscriptions
+
+RustQueue can expose a best-effort live feed for short-lived data without
+changing the NSQ TCP contract. WebSocket support is disabled by default. When
+enabled, each Broker's HTTP listener accepts:
+
+```text
+GET /v1/ws/topics/{topic}
+```
+
+The request must negotiate the `rustqueue.live.v1` subprotocol. The Topic must
+already exist on that Broker and have `deliveryMode=TTL_DISCARD`; the endpoint
+never creates a Topic. A missing Topic and a `RELIABLE` Topic are rejected during
+the handshake with the stable WebSocket error response. One connection covers
+one Topic and starts at the current tail, so messages published before the
+connection—or while it is disconnected—are not replayed.
+
+Because RustQueue is share-nothing, clients first call Discovery `/lookup`,
+then open one WebSocket to the Broker HTTP address for each healthy owner. A
+client can union those streams and use the broker-scoped message ID in the
+binary frame for migration-time deduplication. The producer proxy is not a
+WebSocket fan-out layer.
+
+### Authentication and browser policy
+
+WebSocket authentication reuses the existing subscription permission and
+checks the virtual Channel `websocket#ephemeral`. Service clients may send
+`Authorization: Bearer ` in the HTTP upgrade request. Browser clients
+may complete the `auth_required` / `auth` text-control exchange; credentials
+must never be placed in a query parameter. Browser requests with an `Origin`
+header must exactly match `websocket.allowed_origins`; a request without an
+Origin is treated as a non-browser client and still goes through normal
+authentication. Discovery's read-only `/lookup` endpoint applies the same
+allowed-origin list.
+
+### Binary data frame
+
+Every delivered message is one complete binary WebSocket frame. The first 24
+bytes are big-endian metadata, followed by the raw message body:
+
+| Offset | Size | Field |
+| --- | ---: | --- |
+| `0..4` | 4 | ASCII magic `RQW1` |
+| `4..6` | 2 | Header length, fixed at `24` |
+| `6..8` | 2 | Delivery attempts |
+| `8..16` | 8 | Broker timestamp, signed `i64` nanoseconds |
+| `16..24` | 8 | Broker-scoped message ID, unsigned `u64` |
+| `24..` | — | Original message body |
+
+Text frames are control messages. Clients should handle `auth_required`,
+`ready`, and stable error codes and ignore unknown fields for forward
+compatibility. There is no WebSocket ACK: a message is considered delivered by
+RustQueue only after the complete frame is written, which does not mean the
+client has processed it.
+
+The HTTP `101 Switching Protocols` response establishes only the transport. A
+live subscription begins at the server's `{"type":"ready",...}` control
+frame. No message capture is promised before `ready`, including while the
+server is waiting for the `auth` control frame; clients must not interpret the
+upgrade or `auth_required` frame as the start boundary.
+
+### Stable v1 error codes
+
+Handshake failures reuse the HTTP `ApiError` shape: the stable code is in the
+JSON `message` field and the human-readable explanation is in `detail`. After
+upgrade, terminal errors are sent as a text control such as
+`{"type":"error","code":"E_TOPIC_CLOSED","detail":"..."}` and then the
+connection is closed. Clients should reconnect or repeat `/lookup` according
+to the code:
+
+| Code | Meaning |
+| --- | --- |
+| `E_WS_QUERY_FORBIDDEN` | Query parameters are not accepted; use the path and headers only. |
+| `E_WS_ORIGIN` | The browser `Origin` is not an exact configured allowlist entry. |
+| `E_WS_SUBPROTOCOL` | The upgrade must include `rustqueue.live.v1`. |
+| `E_BAD_TOPIC` | The Topic does not exist on this Broker. |
+| `E_WS_TTL_REQUIRED` | The existing Topic is not `TTL_DISCARD` or changed away from it. |
+| `E_WS_CAPACITY` | Connection, per-Topic, Channel or frame-memory capacity is exhausted; back off. |
+| `E_WS_CONTROL` | An unsupported client text/binary data frame was received. |
+| `E_WS_HEARTBEAT` | The peer missed the heartbeat liveness window; reconnect. |
+| `E_AUTH_TIMEOUT` | The required `auth` control was not received before the AUTH deadline. |
+| `E_BAD_AUTH` | The AUTH control was non-text, malformed, or missing a valid `auth`/`secret` payload. |
+| `E_AUTH_OVERLOADED` | The AUTH service or its capacity gate is exhausted; retry later. |
+| `E_AUTH_FAILED` | The authentication service or credential validation failed. |
+| `E_UNAUTHORIZED` | AUTH succeeded but does not permit this Topic subscription. |
+| `E_TOPIC_CLOSED` | The Topic was deleted, tombstoned, or otherwise closed. |
+| `E_OWNER_CHANGED` | The Topic owner/fence changed; repeat Discovery `/lookup`. |
+| `E_DRAINING` | The Broker is draining and will not keep the live subscription. |
+| `E_SHUTDOWN` | The Broker is shutting down; reconnect after a healthy owner is discovered. |
+
+The stream deliberately has no reliable backlog. At most one message is
+reserved per connection. A failed or timed-out write cancels that reservation
+and closes the connection; RustQueue does not retry the frame over WebSocket.
+The write deadline is the smaller of the socket timeout and the message's
+remaining TTL. Paused Topics stop taking messages. Topic deletion, migration,
+owner shutdown, or a change to `RELIABLE` closes affected connections so the
+client can perform `/lookup` again.
+
+The node-wide frame budget and connection limits are bounded by this default
+runtime configuration:
+
+```toml
+[websocket]
+enabled = false
+max_connections = 1024
+max_connections_per_topic = 256
+frame_inflight_bytes = 134217728
+allowed_origins = []
+```
+
+The same fields can be overridden for a Broker with
+`RUSTQUEUE_WEBSOCKET_ENABLED`, `RUSTQUEUE_WEBSOCKET_MAX_CONNECTIONS`,
+`RUSTQUEUE_WEBSOCKET_MAX_CONNECTIONS_PER_TOPIC`,
+`RUSTQUEUE_WEBSOCKET_FRAME_INFLIGHT_BYTES`, and
+`RUSTQUEUE_WEBSOCKET_ALLOWED_ORIGINS` (a comma-separated list of exact HTTP
+origins). Wildcards and origins containing a path are rejected.
+
+Helm values, the RustQueue Operator spec, and the CRD expose the same settings
+through their Kubernetes naming conventions. Enabling the feature reuses the
+existing Broker HTTP Service and NetworkPolicy; it does not add a port or an
+Ingress. Monitor connection count, completed frame messages/bytes,
+authentication failures, capacity rejections, non-TTL rejections and slow
+consumer closes before increasing the limits.
+
## Local development
The host does not need Rust. Builds and tests use the pinned Docker toolchain:
@@ -240,7 +354,7 @@ kubectl label node worker-1 rustqueue.io/eligible=true
helm upgrade --install rustqueue deploy/helm/rustqueue \
--namespace rustqueue --create-namespace \
- --set queue.image=registry.example/rustqueue:0.9.0 \
+ --set queue.image=registry.example/rustqueue:0.10.0 \
--set queue.storageClassName=ssd-rwo
```
@@ -254,7 +368,7 @@ scrub, upgrade or the NSQ-compatible admin API. Client TLS is optional and
always supplied through an existing Kubernetes Secret; the operator does not
run a CA.
-### Kodo compatibility in 0.9
+### Kodo compatibility
Kodo compatibility is implemented entirely by RustQueue as a separate,
default-off deployment profile. Kodo continues to use RustQueue Discovery:
@@ -477,7 +591,7 @@ test-only direct Pod placement; production anti-affinity is unchanged. A unit
fixture covers discovery indexing for 500 brokers. No 500-broker deployment or
load test is part of the functional gate.
-The v0.9.0 CI/CD workflow publishes a Release only after the non-Kubernetes
+The v0.10.0 CI/CD workflow publishes a Release only after the non-Kubernetes
release gate, both native Linux builds, packaging and checksum verification
succeed. The v0.8.0 Kodo compatibility baseline additionally passed the
unmodified Kodo source replay, an exact 104,857,500-byte `PUB`/`DPUB` with one
@@ -534,6 +648,8 @@ Native broker endpoints:
- `GET /v1/registry`
- `GET /v1/registry/head`
- `GET /v1/capabilities`
+- `GET /v1/ws/topics/{topic}` (WebSocket, only when enabled and only for
+ `TTL_DISCARD` Topics; negotiate `rustqueue.live.v1`)
- `GET|POST /v1/drain`
- `GET /v1/stats`
- `GET /v1/observe` (console token, no message bodies)
@@ -580,7 +696,7 @@ writes. Keep both relaxed profiles separate from durable-PUB results.
## Storage and upgrades
-RustQueue 0.9 keeps disk format v7. Format v7 is a clean break: a v6 or older
+RustQueue 0.10 keeps disk format v7. Format v7 is a clean break: a v6 or older
directory is refused and there is no in-place migration. Within v7, record tags
and existing fields are append-only.
Every binary declares its reader/writer feature range and protocol message/body
@@ -653,6 +769,7 @@ non-Kubernetes gate runs in GitHub Actions.
## Non-goals
-RustQueue 0.9 does not provide message replication, backups, exactly-once
+RustQueue 0.10 does not provide message replication, backups, exactly-once
delivery, a global channel catalog, online data migration, cross-region
-replication, or Broker/PVC lifecycle controls in Console.
+replication, reliable WebSocket replay/resume/ACK semantics, or Broker/PVC
+lifecycle controls in Console.
diff --git a/console-ui/package.json b/console-ui/package.json
index 2f126e4..04922f5 100644
--- a/console-ui/package.json
+++ b/console-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "rustqueue-console-ui",
"private": true,
- "version": "0.9.0",
+ "version": "0.10.0",
"packageManager": "pnpm@11.9.0",
"type": "module",
"scripts": {
diff --git a/console-ui/src/layout/AppShell.tsx b/console-ui/src/layout/AppShell.tsx
index 04654c8..41dfa02 100644
--- a/console-ui/src/layout/AppShell.tsx
+++ b/console-ui/src/layout/AppShell.tsx
@@ -57,7 +57,7 @@ export function AppShell({ page, onPage, dark, onTheme, onRefresh, children }: {
onClick={(event) => { event.preventDefault(); onPage(item.id); }}
>{t(`nav.${item.id}`)})}
- {t('app.readOnly')}v0.9.0
+ {t('app.readOnly')}v0.10.0
{children}
diff --git a/crates/discovery/Cargo.toml b/crates/discovery/Cargo.toml
index 36bfa38..070e18e 100644
--- a/crates/discovery/Cargo.toml
+++ b/crates/discovery/Cargo.toml
@@ -25,6 +25,7 @@ sha2.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
+tower-http.workspace = true
[dev-dependencies]
tower.workspace = true
diff --git a/crates/discovery/src/main.rs b/crates/discovery/src/main.rs
index 2a439f2..3841b95 100644
--- a/crates/discovery/src/main.rs
+++ b/crates/discovery/src/main.rs
@@ -42,6 +42,12 @@ struct Cli {
kodo_gateway_address: Option,
#[arg(long, env = "RUSTQUEUE_KODO_CLEANUP_ENABLED", default_value_t = false)]
kodo_cleanup_enabled: bool,
+ #[arg(
+ long,
+ env = "RUSTQUEUE_WEBSOCKET_ALLOWED_ORIGINS",
+ value_delimiter = ','
+ )]
+ websocket_allowed_origins: Vec,
}
#[tokio::main]
@@ -56,6 +62,24 @@ async fn main() -> anyhow::Result<()> {
if cli.endpoint_slice_timeout_ms == 0 {
anyhow::bail!("EndpointSlice timeout must be greater than zero");
}
+ if cli.websocket_allowed_origins.iter().any(|origin| {
+ let authority = origin
+ .strip_prefix("http://")
+ .or_else(|| origin.strip_prefix("https://"));
+ origin == "*"
+ || authority.is_none_or(|authority| {
+ authority.is_empty()
+ || authority.contains('/')
+ || authority.contains('?')
+ || authority.contains('#')
+ || authority.contains(',')
+ || authority.trim() != authority
+ })
+ }) {
+ anyhow::bail!(
+ "WebSocket allowed origins must be exact HTTP origins without paths or wildcards"
+ );
+ }
if cli.kodo_cleanup_enabled {
anyhow::bail!(
"Kodo automatic cleanup is disabled until cluster-wide atomic deletion is available"
@@ -103,6 +127,6 @@ async fn main() -> anyhow::Result<()> {
);
tokio::select! {
result = refresh => result,
- result = serve(cli.address, directory) => result,
+ result = serve(cli.address, directory, cli.websocket_allowed_origins) => result,
}
}
diff --git a/crates/discovery/src/server.rs b/crates/discovery/src/server.rs
index 0beb498..0238190 100644
--- a/crates/discovery/src/server.rs
+++ b/crates/discovery/src/server.rs
@@ -9,6 +9,8 @@ use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use std::net::SocketAddr;
+use std::time::Duration;
+use tower_http::cors::{AllowOrigin, CorsLayer};
#[derive(Deserialize)]
struct TopicQuery {
@@ -22,10 +24,29 @@ struct ChannelQuery {
}
pub fn router(directory: Directory) -> Router {
+ router_with_origins(directory, &[])
+}
+
+pub fn router_with_origins(directory: Directory, allowed_origins: &[String]) -> Router {
+ let lookup_route = if allowed_origins.is_empty() {
+ get(lookup)
+ } else {
+ let origins = allowed_origins
+ .iter()
+ .filter_map(|origin| HeaderValue::from_str(origin).ok())
+ .collect::>();
+ get(lookup).layer(
+ CorsLayer::new()
+ .allow_origin(AllowOrigin::list(origins))
+ .allow_methods([axum::http::Method::GET])
+ .allow_headers([header::ACCEPT])
+ .max_age(Duration::from_secs(600)),
+ )
+ };
Router::new()
.route("/ping", get(|| async { "OK" }))
.route("/info", get(info))
- .route("/lookup", get(lookup))
+ .route("/lookup", lookup_route)
.route("/topics", get(topics))
.route("/channels", get(channels))
.route("/nodes", get(nodes))
@@ -82,10 +103,14 @@ async fn nsq_content_negotiation(request: Request, next: Next) -> Response
response
}
-pub async fn serve(address: SocketAddr, directory: Directory) -> anyhow::Result<()> {
+pub async fn serve(
+ address: SocketAddr,
+ directory: Directory,
+ allowed_origins: Vec,
+) -> anyhow::Result<()> {
let listener = tokio::net::TcpListener::bind(address).await?;
tracing::info!(%address, "RustQueue discovery API listening");
- axum::serve(listener, router(directory)).await?;
+ axum::serve(listener, router_with_origins(directory, &allowed_origins)).await?;
Ok(())
}
diff --git a/crates/operator/src/controller/mod.rs b/crates/operator/src/controller/mod.rs
index 29deb73..0aa4fd5 100644
--- a/crates/operator/src/controller/mod.rs
+++ b/crates/operator/src/controller/mod.rs
@@ -1380,6 +1380,32 @@ fn validate(cluster: &RustQueue, active_feature_floor: u32) -> anyhow::Result<()
) {
bail!("publishAckMode must be durable, write_ack, or nsq_relaxed");
}
+ if cluster.spec.websocket.max_connections == 0
+ || cluster.spec.websocket.max_connections > tokio::sync::Semaphore::MAX_PERMITS
+ || cluster.spec.websocket.max_connections_per_topic == 0
+ || cluster.spec.websocket.max_connections_per_topic > cluster.spec.websocket.max_connections
+ || cluster.spec.websocket.frame_inflight_bytes
+ < cluster.spec.max_message_bytes.saturating_add(24)
+ || cluster.spec.websocket.frame_inflight_bytes > u32::MAX as usize
+ {
+ bail!("WebSocket limits must fit one maximum message and satisfy per-Topic <= global connections");
+ }
+ if cluster.spec.websocket.allowed_origins.iter().any(|origin| {
+ let authority = origin
+ .strip_prefix("http://")
+ .or_else(|| origin.strip_prefix("https://"));
+ origin == "*"
+ || authority.is_none_or(|authority| {
+ authority.is_empty()
+ || authority.contains('/')
+ || authority.contains('?')
+ || authority.contains('#')
+ || authority.contains(',')
+ || authority.trim() != authority
+ })
+ }) {
+ bail!("WebSocket allowedOrigins must be exact HTTP origins without paths or wildcards");
+ }
validate_message_storage_contract(
cluster.spec.max_message_bytes,
effective_storage_feature_level(cluster.spec.storage_feature_level, active_feature_floor),
diff --git a/crates/operator/src/controller/preflight.rs b/crates/operator/src/controller/preflight.rs
index 68d6d8e..e87e005 100644
--- a/crates/operator/src/controller/preflight.rs
+++ b/crates/operator/src/controller/preflight.rs
@@ -28,6 +28,8 @@ struct BinaryCapabilities {
maximum_message_bytes: usize,
#[serde(default = "legacy_maximum_batch_bytes")]
maximum_batch_bytes: usize,
+ #[serde(default)]
+ websocket_ttl_live_v1: bool,
}
#[derive(Clone, Debug, Deserialize)]
@@ -142,6 +144,7 @@ pub(super) async fn target_image(
cluster.spec.storage_feature_level,
cluster.spec.max_message_bytes,
required_batch_bytes(cluster),
+ cluster.spec.websocket.enabled,
)
.map_or_else(
|message| Ok(Outcome::Blocked(message)),
@@ -279,6 +282,7 @@ fn validate_binary(
desired: u32,
message_bytes: usize,
batch_bytes: usize,
+ websocket_required: bool,
) -> Result<(), String> {
if capabilities.binary_version.trim().is_empty() || capabilities.data_format != DATA_FORMAT {
return Err("target image does not advertise RustQueue format v7".into());
@@ -299,6 +303,9 @@ fn validate_binary(
capabilities.maximum_message_bytes, capabilities.maximum_batch_bytes
));
}
+ if websocket_required && !capabilities.websocket_ttl_live_v1 {
+ return Err("target image does not support WebSocket TTL live subscriptions".into());
+ }
Ok(())
}
@@ -313,6 +320,7 @@ fn validate_report(
desired.max(report.storage.active_writer_feature_level),
message_bytes,
batch_bytes,
+ false,
)?;
if report.storage.data_format != DATA_FORMAT {
return Err("PVC compatibility state is not format v7".into());
@@ -341,13 +349,14 @@ fn legacy_maximum_batch_bytes() -> usize {
fn probe_contract(cluster: &RustQueue, image: &str) -> String {
format!(
- "{}\0{}\0{}\0{}\0{}\0{}\0{}",
+ "{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}",
cluster.metadata.uid.as_deref().unwrap_or_default(),
image,
cluster.spec.image_pull_policy,
cluster.spec.storage_feature_level,
cluster.spec.max_message_bytes,
required_batch_bytes(cluster),
+ cluster.spec.websocket.enabled,
cluster.spec.rollout.retry_nonce,
)
}
@@ -462,13 +471,16 @@ mod tests {
maximum_writer_feature_level: maximum,
maximum_message_bytes: 100 * 1024 * 1024,
maximum_batch_bytes: 128 * 1024 * 1024,
+ websocket_ttl_live_v1: true,
}
}
#[test]
fn target_image_must_support_the_requested_feature() {
- assert!(validate_binary(&binary(2), 2, 100 * 1024 * 1024, 128 * 1024 * 1024).is_ok());
- assert!(validate_binary(&binary(1), 2, 20 * 1024 * 1024, 64 * 1024 * 1024).is_err());
+ assert!(
+ validate_binary(&binary(2), 2, 100 * 1024 * 1024, 128 * 1024 * 1024, false).is_ok()
+ );
+ assert!(validate_binary(&binary(1), 2, 20 * 1024 * 1024, 64 * 1024 * 1024, false).is_err());
}
#[test]
@@ -483,7 +495,19 @@ mod tests {
.unwrap();
assert_eq!(legacy.maximum_message_bytes, legacy_maximum_message_bytes());
assert_eq!(legacy.maximum_batch_bytes, legacy_maximum_batch_bytes());
- assert!(validate_binary(&legacy, 2, 100 * 1024 * 1024, 128 * 1024 * 1024).is_err());
+ assert!(validate_binary(&legacy, 2, 100 * 1024 * 1024, 128 * 1024 * 1024, false).is_err());
+ }
+
+ #[test]
+ fn websocket_capability_is_required_only_when_enabled() {
+ let mut capabilities = binary(2);
+ capabilities.websocket_ttl_live_v1 = false;
+ assert!(
+ validate_binary(&capabilities, 2, 20 * 1024 * 1024, 64 * 1024 * 1024, true,).is_err()
+ );
+ assert!(
+ validate_binary(&capabilities, 2, 20 * 1024 * 1024, 64 * 1024 * 1024, false,).is_ok()
+ );
}
#[test]
diff --git a/crates/operator/src/controller/status.rs b/crates/operator/src/controller/status.rs
index 3060f7f..a9a4f8d 100644
--- a/crates/operator/src/controller/status.rs
+++ b/crates/operator/src/controller/status.rs
@@ -225,6 +225,7 @@ mod tests {
proxy_node_selector: BTreeMap::new(),
proxy_tcp_max_connection_age_seconds: 300,
discovery_replicas: 2,
+ websocket: crate::crd::WebSocketSpec::default(),
kodo_compatibility: crate::crd::KodoCompatibility::default(),
maintenance: None,
rollout: RolloutPolicy::default(),
diff --git a/crates/operator/src/crd.rs b/crates/operator/src/crd.rs
index 74dec16..3005453 100644
--- a/crates/operator/src/crd.rs
+++ b/crates/operator/src/crd.rs
@@ -82,6 +82,8 @@ pub struct RustQueueSpec {
#[serde(default = "default_discovery_replicas")]
pub discovery_replicas: i32,
#[serde(default)]
+ pub websocket: WebSocketSpec,
+ #[serde(default)]
pub kodo_compatibility: KodoCompatibility,
#[serde(default)]
pub maintenance: Option,
@@ -93,6 +95,33 @@ pub struct RustQueueSpec {
pub broker_resources: WorkloadResources,
}
+#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct WebSocketSpec {
+ #[serde(default)]
+ pub enabled: bool,
+ #[serde(default = "default_websocket_max_connections")]
+ pub max_connections: usize,
+ #[serde(default = "default_websocket_max_connections_per_topic")]
+ pub max_connections_per_topic: usize,
+ #[serde(default = "default_websocket_frame_inflight_bytes")]
+ pub frame_inflight_bytes: usize,
+ #[serde(default)]
+ pub allowed_origins: Vec,
+}
+
+impl Default for WebSocketSpec {
+ fn default() -> Self {
+ Self {
+ enabled: false,
+ max_connections: default_websocket_max_connections(),
+ max_connections_per_topic: default_websocket_max_connections_per_topic(),
+ frame_inflight_bytes: default_websocket_frame_inflight_bytes(),
+ allowed_origins: Vec::new(),
+ }
+ }
+}
+
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct KodoCompatibility {
@@ -375,6 +404,15 @@ fn default_max_detailed_metric_series() -> usize {
fn default_discovery_replicas() -> i32 {
2
}
+fn default_websocket_max_connections() -> usize {
+ 1_024
+}
+fn default_websocket_max_connections_per_topic() -> usize {
+ 256
+}
+fn default_websocket_frame_inflight_bytes() -> usize {
+ 128 * 1024 * 1024
+}
fn default_rollout_timeout() -> u64 {
600
}
diff --git a/crates/operator/src/lib.rs b/crates/operator/src/lib.rs
index 08d3a3b..eda579f 100644
--- a/crates/operator/src/lib.rs
+++ b/crates/operator/src/lib.rs
@@ -5,7 +5,7 @@ pub mod resources;
pub use crd::{
BrokerMaintenance, BrokerScheduling, BrokerToleration, KodoCompatibility, OperationStatus,
- RolloutPolicy, RustQueue, RustQueueCondition, RustQueueSpec, RustQueueStatus,
+ RolloutPolicy, RustQueue, RustQueueCondition, RustQueueSpec, RustQueueStatus, WebSocketSpec,
WorkloadResources,
};
pub use management_crd::{
diff --git a/crates/operator/src/resources.rs b/crates/operator/src/resources.rs
index 29229d8..01bd6f8 100644
--- a/crates/operator/src/resources.rs
+++ b/crates/operator/src/resources.rs
@@ -255,6 +255,12 @@ pub fn build(input: BuildInput<'_>) -> anyhow::Result {
json!({"name": "RUSTQUEUE_BROKER_SERVICE", "value": broker_service_name}),
json!({"name": "RUSTQUEUE_REGISTRY_TOKEN_FILE", "value": "/run/secrets/rustqueue/registry-token"}),
];
+ if !cluster.spec.websocket.allowed_origins.is_empty() {
+ discovery_env.push(json!({
+ "name": "RUSTQUEUE_WEBSOCKET_ALLOWED_ORIGINS",
+ "value": cluster.spec.websocket.allowed_origins.join(",")
+ }));
+ }
if input.advertise_kodo_gateways {
let cleanup_enabled = input.activate_kodo_cleanup
&& cluster.spec.kodo_compatibility.effective_cleanup_enabled();
@@ -533,8 +539,26 @@ fn broker_config(cluster: &RustQueue, secret_name: &str) -> String {
} else {
""
};
+ let websocket_origins = cluster
+ .spec
+ .websocket
+ .allowed_origins
+ .iter()
+ .map(|origin| serde_json::to_string(origin).expect("origin is serializable"))
+ .collect::>()
+ .join(", ");
+ let websocket = if cluster.spec.websocket.enabled {
+ format!(
+ "[websocket]\nenabled = true\nmax_connections = {}\nmax_connections_per_topic = {}\nframe_inflight_bytes = {}\nallowed_origins = [{websocket_origins}]\n\n",
+ cluster.spec.websocket.max_connections,
+ cluster.spec.websocket.max_connections_per_topic,
+ cluster.spec.websocket.frame_inflight_bytes,
+ )
+ } else {
+ String::new()
+ };
let mut output = format!(
- "{kodo_network}[storage]\ndata_path = \"/data\"\nfeature_level = {}\nmax_segment_bytes = {max_segment_bytes}\nmin_free_bytes = {}\ndisk_high_watermark_percent = {}\ndisk_low_watermark_percent = {}\nprotective_eviction_enabled = {}\ndisk_pressure_grace_seconds = {}\nmaintenance_startup_delay_seconds = {}\n\n[queue]\nbootstrap_retention_seconds = {}\nmax_message_bytes = {}\nmax_topics = {}\nmax_publish_workers = {}\npublish_worker_idle_seconds = {}\npublish_ack_mode = \"{}\"\nrelaxed_sync_messages = {}\nrelaxed_sync_bytes = {}\nrelaxed_sync_interval_ms = {}\n\n[limits]\nmax_body_bytes = {max_body_bytes}\nnode_publish_inflight_bytes = {node_publish_inflight_bytes}\nconnection_publish_inflight_bytes = {connection_publish_inflight_bytes}\nnode_delivery_inflight_bytes = {}\nconnection_delivery_inflight_bytes = {}\ndisconnect_on_retriable_publish_error = false\n\n[metrics]\ndetailed_queue_metrics = {}\nmax_detailed_series = {}\n\n[security]\nadmin_token_file = \"/run/secrets/rustqueue/admin-token\"\n{publish_token}registry_token_file = \"/run/secrets/rustqueue/registry-token\"\nconsole_token_file = \"/run/secrets/rustqueue/console-token\"\n{kodo_cleanup_token}console_management_enabled = {}\nkodo_cleanup_enabled = {}\n# secret: {secret_name}\n",
+ "{kodo_network}{websocket}[storage]\ndata_path = \"/data\"\nfeature_level = {}\nmax_segment_bytes = {max_segment_bytes}\nmin_free_bytes = {}\ndisk_high_watermark_percent = {}\ndisk_low_watermark_percent = {}\nprotective_eviction_enabled = {}\ndisk_pressure_grace_seconds = {}\nmaintenance_startup_delay_seconds = {}\n\n[queue]\nbootstrap_retention_seconds = {}\nmax_message_bytes = {}\nmax_topics = {}\nmax_publish_workers = {}\npublish_worker_idle_seconds = {}\npublish_ack_mode = \"{}\"\nrelaxed_sync_messages = {}\nrelaxed_sync_bytes = {}\nrelaxed_sync_interval_ms = {}\n\n[limits]\nmax_body_bytes = {max_body_bytes}\nnode_publish_inflight_bytes = {node_publish_inflight_bytes}\nconnection_publish_inflight_bytes = {connection_publish_inflight_bytes}\nnode_delivery_inflight_bytes = {}\nconnection_delivery_inflight_bytes = {}\ndisconnect_on_retriable_publish_error = false\n\n[metrics]\ndetailed_queue_metrics = {}\nmax_detailed_series = {}\n\n[security]\nadmin_token_file = \"/run/secrets/rustqueue/admin-token\"\n{publish_token}registry_token_file = \"/run/secrets/rustqueue/registry-token\"\nconsole_token_file = \"/run/secrets/rustqueue/console-token\"\n{kodo_cleanup_token}console_management_enabled = {}\nkodo_cleanup_enabled = {}\n# secret: {secret_name}\n",
cluster.spec.storage_feature_level,
cluster.spec.min_free_bytes,
cluster.spec.disk_high_watermark_percent,
@@ -666,6 +690,7 @@ mod tests {
proxy_node_selector: BTreeMap::new(),
proxy_tcp_max_connection_age_seconds: 300,
discovery_replicas: 2,
+ websocket: crate::crd::WebSocketSpec::default(),
kodo_compatibility: crate::crd::KodoCompatibility::default(),
maintenance: None,
rollout: crate::crd::RolloutPolicy::default(),
@@ -847,6 +872,28 @@ mod tests {
);
}
+ #[test]
+ fn websocket_broker_config_is_opt_in_and_renders_limits_and_origins() {
+ let default_config = broker_config(&cluster(), "queue-auth");
+ assert!(!default_config.contains("[websocket]"));
+
+ let mut websocket_cluster = cluster();
+ websocket_cluster.spec.websocket.enabled = true;
+ websocket_cluster.spec.websocket.allowed_origins = vec![
+ "https://console.example.test".into(),
+ "http://127.0.0.1:14151".into(),
+ ];
+ let websocket_config = broker_config(&websocket_cluster, "queue-auth");
+
+ assert!(websocket_config.contains("[websocket]\nenabled = true"));
+ assert!(websocket_config.contains("max_connections = 1024"));
+ assert!(websocket_config.contains("max_connections_per_topic = 256"));
+ assert!(websocket_config.contains("frame_inflight_bytes = 134217728"));
+ assert!(websocket_config.contains(
+ "allowed_origins = [\"https://console.example.test\", \"http://127.0.0.1:14151\"]"
+ ));
+ }
+
#[test]
fn kodo_profile_renders_three_stable_gateways_and_large_message_limits() {
let mut cluster = cluster();
diff --git a/crates/queue/src/broker.rs b/crates/queue/src/broker.rs
index bd9eafa..a41f6f4 100644
--- a/crates/queue/src/broker.rs
+++ b/crates/queue/src/broker.rs
@@ -466,6 +466,23 @@ impl Broker {
}
pub async fn create_channel(&self, topic: &str, channel: &str) -> Result<(), BrokerError> {
+ self.create_channel_inner(topic, channel, true).await
+ }
+
+ pub async fn create_channel_existing(
+ &self,
+ topic: &str,
+ channel: &str,
+ ) -> Result<(), BrokerError> {
+ self.create_channel_inner(topic, channel, false).await
+ }
+
+ async fn create_channel_inner(
+ &self,
+ topic: &str,
+ channel: &str,
+ create_topic: bool,
+ ) -> Result<(), BrokerError> {
validate_name(topic).map_err(|_| BrokerError::InvalidTopic)?;
validate_channel(channel)?;
self.ensure_management_access(topic, Some(channel))?;
@@ -475,7 +492,11 @@ impl Broker {
self.storage_task(move || {
let _lifecycle = broker.inner.topic_lifecycle.lock();
broker.ensure_management_access(&topic, Some(&channel))?;
- let handle = broker.get_or_create_topic_locked(&topic)?;
+ let handle = if create_topic {
+ broker.get_or_create_topic_locked(&topic)?
+ } else {
+ broker.topic(&topic)?
+ };
let _commit_gate = handle.commit_gate.lock();
let _channel_commit_gate = handle.channel_commit_gate.lock();
if handle
diff --git a/crates/queue/src/broker/expiration.rs b/crates/queue/src/broker/expiration.rs
index 3358b2e..be68d2a 100644
--- a/crates/queue/src/broker/expiration.rs
+++ b/crates/queue/src/broker/expiration.rs
@@ -131,6 +131,10 @@ impl Broker {
Ok(self.topic(topic)?.subscribe_policy())
}
+ pub fn topic_reliable_policy_epoch(&self, topic: &str) -> Result {
+ Ok(self.topic(topic)?.reliable_policy_epoch())
+ }
+
pub async fn configure_topic_policy(
&self,
topic: &str,
diff --git a/crates/queue/src/broker_tests.rs b/crates/queue/src/broker_tests.rs
index e8ffa2a..b75152b 100644
--- a/crates/queue/src/broker_tests.rs
+++ b/crates/queue/src/broker_tests.rs
@@ -1428,6 +1428,24 @@ async fn broker_rejects_unbounded_durable_topic_creation() {
assert_eq!(broker.topic_names(), vec!["first"]);
}
+#[tokio::test]
+async fn create_channel_existing_rejects_missing_topic_without_creating_it() {
+ let root = tempdir().unwrap();
+ let broker = Broker::open(BrokerConfig {
+ data_path: root.path().into(),
+ ..BrokerConfig::default()
+ })
+ .unwrap();
+
+ let error = broker
+ .create_channel_existing("events", "workers")
+ .await
+ .unwrap_err();
+
+ assert!(matches!(error, BrokerError::TopicNotFound));
+ assert!(broker.topic_names().is_empty());
+}
+
#[tokio::test]
async fn sealed_backlog_uses_bounded_metadata_residency() {
let root = tempdir().unwrap();
@@ -2940,6 +2958,81 @@ async fn ttl_policy_changes_apply_to_backlog_without_reviving_expired_messages()
assert_eq!(broker.stats().topics[0].ttl_discarded_messages, 1);
}
+#[tokio::test]
+async fn ttl_policy_watch_keeps_reliable_epoch_across_coalesced_changes() {
+ let root = tempdir().unwrap();
+ let broker = Broker::open(BrokerConfig {
+ data_path: root.path().into(),
+ ..BrokerConfig::default()
+ })
+ .unwrap();
+ broker.create_topic("policy-watch").await.unwrap();
+ broker
+ .configure_topic_policy(
+ "policy-watch",
+ TopicPolicy {
+ delivery_mode: DeliveryMode::TtlDiscard,
+ message_ttl_seconds: Some(30),
+ },
+ )
+ .await
+ .unwrap();
+
+ let mut policy_changes = broker.subscribe_topic_policy("policy-watch").unwrap();
+ let initial_epoch = broker.topic_reliable_policy_epoch("policy-watch").unwrap();
+
+ broker
+ .configure_topic_policy(
+ "policy-watch",
+ TopicPolicy {
+ delivery_mode: DeliveryMode::TtlDiscard,
+ message_ttl_seconds: Some(60),
+ },
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ broker.topic_reliable_policy_epoch("policy-watch").unwrap(),
+ initial_epoch
+ );
+ policy_changes.changed().await.unwrap();
+ assert_eq!(
+ *policy_changes.borrow_and_update(),
+ TopicPolicy {
+ delivery_mode: DeliveryMode::TtlDiscard,
+ message_ttl_seconds: Some(60),
+ }
+ );
+
+ broker
+ .configure_topic_policy("policy-watch", TopicPolicy::default())
+ .await
+ .unwrap();
+ broker
+ .configure_topic_policy(
+ "policy-watch",
+ TopicPolicy {
+ delivery_mode: DeliveryMode::TtlDiscard,
+ message_ttl_seconds: Some(90),
+ },
+ )
+ .await
+ .unwrap();
+
+ policy_changes.changed().await.unwrap();
+ assert_eq!(
+ *policy_changes.borrow_and_update(),
+ TopicPolicy {
+ delivery_mode: DeliveryMode::TtlDiscard,
+ message_ttl_seconds: Some(90),
+ }
+ );
+ assert_eq!(
+ broker.topic_reliable_policy_epoch("policy-watch").unwrap(),
+ initial_epoch + 1
+ );
+}
+
#[tokio::test]
async fn ttl_discard_evicts_durable_ephemeral_deferred_requeued_and_in_flight_state() {
let root = tempdir().unwrap();
diff --git a/crates/queue/src/topic.rs b/crates/queue/src/topic.rs
index 0ed3270..9016d4a 100644
--- a/crates/queue/src/topic.rs
+++ b/crates/queue/src/topic.rs
@@ -22,6 +22,7 @@ use rustqueue_storage::{RecordHeader, RecordKind, SegmentLog};
use std::collections::HashMap;
use std::fs::File;
use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -42,6 +43,7 @@ pub(crate) struct TopicHandle {
pub state: Mutex,
pub wake: tokio::sync::watch::Sender,
policy_changes: tokio::sync::watch::Sender,
+ reliable_policy_epoch: AtomicU64,
}
pub(crate) struct Topic {
@@ -97,6 +99,7 @@ impl TopicHandle {
state: Mutex::new(topic),
wake,
policy_changes,
+ reliable_policy_epoch: AtomicU64::new(0),
}))
}
@@ -154,6 +157,7 @@ impl TopicHandle {
}),
wake,
policy_changes,
+ reliable_policy_epoch: AtomicU64::new(0),
}))
}
@@ -165,6 +169,9 @@ impl TopicHandle {
pub fn set_policy(&self, topic: &mut Topic, policy: TopicPolicy) -> Result {
let changed = topic.set_policy(policy)?;
if changed {
+ if policy.delivery_mode == DeliveryMode::Reliable {
+ self.reliable_policy_epoch.fetch_add(1, Ordering::AcqRel);
+ }
self.policy_changes.send_replace(policy);
self.signal();
}
@@ -174,6 +181,10 @@ impl TopicHandle {
pub fn subscribe_policy(&self) -> tokio::sync::watch::Receiver {
self.policy_changes.subscribe()
}
+
+ pub fn reliable_policy_epoch(&self) -> u64 {
+ self.reliable_policy_epoch.load(Ordering::Acquire)
+ }
}
impl Topic {
diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs
index 6ee62c1..0d94352 100644
--- a/crates/server/src/config.rs
+++ b/crates/server/src/config.rs
@@ -19,6 +19,7 @@ pub struct RuntimeCapabilities {
storage: rustqueue_storage::BinaryCapabilities,
pub maximum_message_bytes: usize,
pub maximum_batch_bytes: usize,
+ pub websocket_ttl_live_v1: bool,
}
pub fn runtime_capabilities() -> RuntimeCapabilities {
@@ -26,6 +27,7 @@ pub fn runtime_capabilities() -> RuntimeCapabilities {
storage: rustqueue_storage::binary_capabilities(),
maximum_message_bytes: MAX_SUPPORTED_MESSAGE_BYTES,
maximum_batch_bytes: MAX_SUPPORTED_BATCH_BYTES,
+ websocket_ttl_live_v1: true,
}
}
@@ -34,6 +36,7 @@ pub fn runtime_capabilities() -> RuntimeCapabilities {
pub struct Config {
pub node: NodeConfig,
pub network: NetworkConfig,
+ pub websocket: WebSocketConfig,
pub storage: StorageConfig,
pub queue: QueueConfig,
pub security: SecurityConfig,
@@ -43,6 +46,16 @@ pub struct Config {
pub log_format: String,
}
+#[derive(Clone, Debug, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct WebSocketConfig {
+ pub enabled: bool,
+ pub max_connections: usize,
+ pub max_connections_per_topic: usize,
+ pub frame_inflight_bytes: usize,
+ pub allowed_origins: Vec,
+}
+
#[derive(Clone, Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NodeConfig {
@@ -174,6 +187,7 @@ impl Default for Config {
Self {
node: NodeConfig::default(),
network: NetworkConfig::default(),
+ websocket: WebSocketConfig::default(),
storage: StorageConfig::default(),
queue: QueueConfig::default(),
security: SecurityConfig::default(),
@@ -185,6 +199,18 @@ impl Default for Config {
}
}
+impl Default for WebSocketConfig {
+ fn default() -> Self {
+ Self {
+ enabled: false,
+ max_connections: 1_024,
+ max_connections_per_topic: 256,
+ frame_inflight_bytes: 128 * 1024 * 1024,
+ allowed_origins: Vec::new(),
+ }
+ }
+}
+
impl Default for NodeConfig {
fn default() -> Self {
Self {
@@ -403,6 +429,31 @@ impl Config {
if !(1..=9).contains(&self.network.max_deflate_level) {
bail!("network.max_deflate_level must be in 1..=9");
}
+ if self.websocket.max_connections == 0
+ || self.websocket.max_connections > tokio::sync::Semaphore::MAX_PERMITS
+ || self.websocket.max_connections_per_topic == 0
+ || self.websocket.max_connections_per_topic > self.websocket.max_connections
+ || self.websocket.frame_inflight_bytes < self.queue.max_message_bytes.saturating_add(24)
+ || self.websocket.frame_inflight_bytes > u32::MAX as usize
+ {
+ bail!("websocket connection limits must be non-zero and frame_inflight_bytes must fit at least one maximum message and u32");
+ }
+ if self.websocket.allowed_origins.iter().any(|origin| {
+ let authority = origin
+ .strip_prefix("http://")
+ .or_else(|| origin.strip_prefix("https://"));
+ origin == "*"
+ || authority.is_none_or(|authority| {
+ authority.is_empty()
+ || authority.contains('/')
+ || authority.contains('?')
+ || authority.contains('#')
+ || authority.contains(',')
+ || authority.trim() != authority
+ })
+ }) {
+ bail!("websocket.allowed_origins must contain exact HTTP origins without paths or wildcards");
+ }
if self.security.kodo_cleanup_enabled {
bail!(
"Kodo automatic cleanup is disabled until cluster-wide atomic deletion is available"
diff --git a/crates/server/src/config/environment.rs b/crates/server/src/config/environment.rs
index deeff37..e692912 100644
--- a/crates/server/src/config/environment.rs
+++ b/crates/server/src/config/environment.rs
@@ -27,6 +27,27 @@ impl Config {
)?;
set_from_env("RUSTQUEUE_TCP_ADDRESS", &mut self.network.tcp_address)?;
set_from_env("RUSTQUEUE_HTTP_ADDRESS", &mut self.network.http_address)?;
+ set_from_env("RUSTQUEUE_WEBSOCKET_ENABLED", &mut self.websocket.enabled)?;
+ set_from_env(
+ "RUSTQUEUE_WEBSOCKET_MAX_CONNECTIONS",
+ &mut self.websocket.max_connections,
+ )?;
+ set_from_env(
+ "RUSTQUEUE_WEBSOCKET_MAX_CONNECTIONS_PER_TOPIC",
+ &mut self.websocket.max_connections_per_topic,
+ )?;
+ set_from_env(
+ "RUSTQUEUE_WEBSOCKET_FRAME_INFLIGHT_BYTES",
+ &mut self.websocket.frame_inflight_bytes,
+ )?;
+ if let Ok(value) = env::var("RUSTQUEUE_WEBSOCKET_ALLOWED_ORIGINS") {
+ self.websocket.allowed_origins = value
+ .split(',')
+ .map(str::trim)
+ .filter(|origin| !origin.is_empty())
+ .map(str::to_owned)
+ .collect();
+ }
if let Ok(value) = env::var("RUSTQUEUE_KODO_HTTP_ADDRESS") {
self.network.kodo_http_address = Some(
value
diff --git a/crates/server/src/config/tests.rs b/crates/server/src/config/tests.rs
index 4132b77..3f3d2ef 100644
--- a/crates/server/src/config/tests.rs
+++ b/crates/server/src/config/tests.rs
@@ -66,6 +66,19 @@ fn binary_capabilities_advertise_protocol_limits() {
);
}
+#[test]
+fn websocket_defaults_are_opt_in_and_capability_is_advertised() {
+ let config = Config::default();
+ assert!(!config.websocket.enabled);
+ assert_eq!(config.websocket.max_connections, 1_024);
+ assert_eq!(config.websocket.max_connections_per_topic, 256);
+ assert_eq!(config.websocket.frame_inflight_bytes, 128 * 1024 * 1024);
+ assert!(config.websocket.allowed_origins.is_empty());
+
+ let capabilities = serde_json::to_value(runtime_capabilities()).unwrap();
+ assert_eq!(capabilities["websocket_ttl_live_v1"], true);
+}
+
#[test]
fn kodo_cleanup_is_disabled_until_cluster_wide_deletion_is_atomic() {
let root = tempfile::tempdir().unwrap();
diff --git a/crates/server/src/http.rs b/crates/server/src/http.rs
index 40bd6fe..eb8dec0 100644
--- a/crates/server/src/http.rs
+++ b/crates/server/src/http.rs
@@ -5,6 +5,7 @@ mod manage;
mod native;
mod nsq_stats;
mod tokens;
+mod websocket;
use compat::*;
use helpers::*;
@@ -12,11 +13,14 @@ use manage::*;
use native::*;
use nsq_stats::*;
use tokens::TokenSet;
+use websocket::{lookup_cors, WebSocketRuntime};
use crate::admission::PublishAdmission;
+use crate::auth::Authenticator;
use crate::config::Config;
use crate::metrics::Metrics;
use crate::subscriptions::SubscriptionRegistry;
+use crate::tcp::EphemeralConsumers;
use axum::body::{Body, Bytes};
use axum::extract::{Query, State};
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
@@ -45,6 +49,10 @@ struct AppState {
delivering: Arc,
publish_admission: Arc,
subscriptions: SubscriptionRegistry,
+ authenticator: Option>,
+ ephemeral_consumers: EphemeralConsumers,
+ websocket: WebSocketRuntime,
+ shutdown: tokio::sync::watch::Receiver,
started_at: i64,
}
@@ -115,6 +123,8 @@ pub async fn serve(
delivering: Arc,
publish_admission: Arc,
subscriptions: SubscriptionRegistry,
+ authenticator: Option>,
+ ephemeral_consumers: EphemeralConsumers,
shutdown: tokio::sync::watch::Receiver,
) -> anyhow::Result<()> {
let state = app_state(
@@ -125,14 +135,21 @@ pub async fn serve(
delivering,
publish_admission,
subscriptions,
+ authenticator,
+ ephemeral_consumers,
+ shutdown.clone(),
)?;
+ let lookup_route = match lookup_cors(&state.config.websocket.allowed_origins) {
+ Some(cors) => get(lookup).layer(cors),
+ None => get(lookup),
+ };
let mut router = Router::new()
.route("/ping", get(ping))
.route("/info", get(info_handler))
.route("/pub", post(publish))
.route("/mpub", post(multi_publish))
.route("/stats", get(stats))
- .route("/lookup", get(lookup))
+ .route("/lookup", lookup_route)
.route("/topics", get(topics))
.route("/channels", get(channels))
.route("/nodes", get(nodes))
@@ -157,6 +174,9 @@ pub async fn serve(
.route("/v1/observe/head", get(observe_head))
.route("/v1/storage/scrub", post(scrub))
.layer(middleware::from_fn(nsq_content_negotiation));
+ if state.config.websocket.enabled {
+ router = router.route("/v1/ws/topics/{topic}", get(websocket::subscribe_topic));
+ }
if state.config.security.console_management_enabled {
router = router
.route("/v1/manage/topics/{action}", post(manage_topic))
@@ -178,9 +198,12 @@ pub async fn serve(
let listener = TcpListener::bind(address).await?;
info!(%address, "HTTP API listening");
let reloader = tokio::spawn(tokens.reload(token_shutdown));
- let result = axum::serve(listener, router)
- .with_graceful_shutdown(wait_for_shutdown(shutdown))
- .await;
+ let result = axum::serve(
+ listener,
+ router.into_make_service_with_connect_info::(),
+ )
+ .with_graceful_shutdown(wait_for_shutdown(shutdown))
+ .await;
reloader.abort();
result?;
Ok(())
@@ -197,6 +220,7 @@ async fn wait_for_shutdown(mut shutdown: tokio::sync::watch::Receiver) {
}
}
+#[allow(clippy::too_many_arguments)]
fn app_state(
config: Arc,
broker: Arc,
@@ -205,6 +229,9 @@ fn app_state(
delivering: Arc,
publish_admission: Arc,
subscriptions: SubscriptionRegistry,
+ authenticator: Option>,
+ ephemeral_consumers: EphemeralConsumers,
+ shutdown: tokio::sync::watch::Receiver,
) -> anyhow::Result {
Ok(AppState {
tokens: TokenSet::from_config(&config)?,
@@ -215,6 +242,10 @@ fn app_state(
delivering,
publish_admission,
subscriptions,
+ authenticator,
+ ephemeral_consumers,
+ websocket: WebSocketRuntime::new(&config.websocket),
+ shutdown,
started_at: unix_seconds(),
})
}
diff --git a/crates/server/src/http/kodo_compat.rs b/crates/server/src/http/kodo_compat.rs
index 2e31846..f55286e 100644
--- a/crates/server/src/http/kodo_compat.rs
+++ b/crates/server/src/http/kodo_compat.rs
@@ -23,6 +23,9 @@ pub(crate) async fn serve_kodo_compat(
delivering,
publish_admission,
subscriptions,
+ None,
+ EphemeralConsumers::default(),
+ shutdown.clone(),
)?;
let tokens = state.tokens.clone();
let token_shutdown = shutdown.clone();
@@ -69,6 +72,10 @@ mod tests {
delivering: Arc::new(AtomicBool::new(true)),
publish_admission: Arc::new(PublishAdmission::new(1024, metrics)),
subscriptions: SubscriptionRegistry::default(),
+ authenticator: None,
+ ephemeral_consumers: EphemeralConsumers::default(),
+ websocket: WebSocketRuntime::new(&Config::default().websocket),
+ shutdown: tokio::sync::watch::channel(false).1,
started_at: 1,
};
diff --git a/crates/server/src/http/websocket.rs b/crates/server/src/http/websocket.rs
new file mode 100644
index 0000000..dea7794
--- /dev/null
+++ b/crates/server/src/http/websocket.rs
@@ -0,0 +1,1061 @@
+use super::*;
+use crate::auth::{AuthError, AuthSession, Authenticator};
+use crate::subscriptions::ClientIdentity;
+use crate::tcp::EphemeralConsumers;
+use axum::extract::connect_info::ConnectInfo;
+use axum::extract::ws::{CloseFrame, Message, Utf8Bytes, WebSocket, WebSocketUpgrade};
+use axum::extract::Path;
+use bytes::{BufMut, BytesMut};
+use parking_lot::Mutex;
+use rustqueue_queue::{BrokerError, Delivery, DeliveryGuard, DeliveryMode, TopicPolicy};
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::net::SocketAddr;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+use tokio::sync::{OwnedSemaphorePermit, Semaphore};
+use tower_http::cors::{AllowOrigin, CorsLayer};
+use tracing::debug;
+
+const SUBPROTOCOL: &str = "rustqueue.live.v1";
+const AUTH_CHANNEL: &str = "websocket#ephemeral";
+const FRAME_MAGIC: &[u8; 4] = b"RQW1";
+const FRAME_HEADER_BYTES: usize = 24;
+const MAX_CONTROL_BYTES: usize = 64 * 1024;
+const CONTROL_WRITE_TIMEOUT: Duration = Duration::from_secs(1);
+
+#[derive(Clone)]
+pub(super) struct WebSocketRuntime {
+ connections: Arc,
+ frame_bytes: Arc,
+ topics: Arc>>,
+ max_connections_per_topic: usize,
+ next_id: Arc,
+}
+
+pub(super) struct ConnectionAdmission {
+ runtime: WebSocketRuntime,
+ topic: String,
+ _connection: OwnedSemaphorePermit,
+}
+
+#[derive(Debug)]
+enum AdmissionError {
+ Global,
+ Topic,
+}
+
+#[derive(Deserialize)]
+struct AuthControl {
+ #[serde(rename = "type")]
+ kind: String,
+ secret: String,
+}
+
+struct SessionAuth {
+ session: Option,
+ secret: Option,
+}
+
+struct ControlAuthError {
+ code: &'static str,
+ detail: String,
+}
+
+impl WebSocketRuntime {
+ pub(super) fn new(config: &crate::config::WebSocketConfig) -> Self {
+ Self {
+ connections: Arc::new(Semaphore::new(config.max_connections)),
+ frame_bytes: Arc::new(Semaphore::new(config.frame_inflight_bytes)),
+ topics: Arc::new(Mutex::new(HashMap::new())),
+ max_connections_per_topic: config.max_connections_per_topic,
+ next_id: Arc::new(AtomicU64::new(0)),
+ }
+ }
+
+ fn admit(&self, topic: &str) -> Result<(u64, ConnectionAdmission), AdmissionError> {
+ let connection = Arc::clone(&self.connections)
+ .try_acquire_owned()
+ .map_err(|_| AdmissionError::Global)?;
+ let mut topics = self.topics.lock();
+ let count = topics.entry(topic.to_owned()).or_default();
+ if *count >= self.max_connections_per_topic {
+ return Err(AdmissionError::Topic);
+ }
+ *count += 1;
+ let id = self.next_id.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
+ Ok((
+ id,
+ ConnectionAdmission {
+ runtime: self.clone(),
+ topic: topic.to_owned(),
+ _connection: connection,
+ },
+ ))
+ }
+
+ fn reserve_frame(&self, bytes: usize) -> Option {
+ let permits = u32::try_from(bytes.max(1)).ok()?;
+ Arc::clone(&self.frame_bytes)
+ .try_acquire_many_owned(permits)
+ .ok()
+ }
+}
+
+impl Drop for ConnectionAdmission {
+ fn drop(&mut self) {
+ let mut topics = self.runtime.topics.lock();
+ let remove = topics.get_mut(&self.topic).is_some_and(|count| {
+ *count = count.saturating_sub(1);
+ *count == 0
+ });
+ if remove {
+ topics.remove(&self.topic);
+ }
+ }
+}
+
+pub(super) fn lookup_cors(allowed_origins: &[String]) -> Option {
+ let origins = allowed_origins
+ .iter()
+ .filter_map(|origin| HeaderValue::from_str(origin).ok())
+ .collect::>();
+ (!origins.is_empty()).then(|| {
+ CorsLayer::new()
+ .allow_origin(AllowOrigin::list(origins))
+ .allow_methods([axum::http::Method::GET])
+ .allow_headers([header::ACCEPT])
+ .max_age(Duration::from_secs(600))
+ })
+}
+
+pub(super) async fn subscribe_topic(
+ State(state): State,
+ Path(topic): Path,
+ ConnectInfo(peer): ConnectInfo,
+ uri: axum::http::Uri,
+ headers: HeaderMap,
+ ws: WebSocketUpgrade,
+) -> Result {
+ if uri.query().is_some() {
+ return Err(ApiError::bad_request(
+ "E_WS_QUERY_FORBIDDEN",
+ "WebSocket subscriptions do not accept query parameters",
+ ));
+ }
+ ensure_origin_allowed(&state.config.websocket.allowed_origins, &headers)?;
+ if !ws
+ .requested_protocols()
+ .any(|protocol| protocol == SUBPROTOCOL)
+ {
+ return Err(ApiError::bad_request(
+ "E_WS_SUBPROTOCOL",
+ format!("Sec-WebSocket-Protocol must include {SUBPROTOCOL}"),
+ ));
+ }
+ let reliable_policy_epoch = state.broker.topic_reliable_policy_epoch(&topic)?;
+ let policy = state.broker.topic_policy(&topic)?;
+ if policy.delivery_mode != DeliveryMode::TtlDiscard {
+ state
+ .metrics
+ .websocket_non_ttl_rejections
+ .fetch_add(1, Ordering::Relaxed);
+ return Err(ApiError::conflict(
+ "E_WS_TTL_REQUIRED",
+ "WebSocket subscriptions require a TTL_DISCARD Topic",
+ ));
+ }
+ if !state.delivering.load(Ordering::Acquire) {
+ return Err(ApiError::unavailable(
+ "E_NOT_READY",
+ "Broker is not accepting new subscriptions",
+ ));
+ }
+ let (id, admission) = state.websocket.admit(&topic).map_err(|error| {
+ state
+ .metrics
+ .websocket_capacity_rejections
+ .fetch_add(1, Ordering::Relaxed);
+ match error {
+ AdmissionError::Global => ApiError {
+ status: StatusCode::TOO_MANY_REQUESTS,
+ code: "E_WS_CAPACITY",
+ detail: "WebSocket connection capacity is exhausted".into(),
+ },
+ AdmissionError::Topic => ApiError {
+ status: StatusCode::TOO_MANY_REQUESTS,
+ code: "E_WS_CAPACITY",
+ detail: "WebSocket Topic connection capacity is exhausted".into(),
+ },
+ }
+ })?;
+ let session_auth = authenticate_header(&state, peer, &topic, &headers).await?;
+ let max_write_buffer = state
+ .config
+ .queue
+ .max_message_bytes
+ .saturating_add(FRAME_HEADER_BYTES + 1024);
+ Ok(ws
+ .protocols([SUBPROTOCOL])
+ .write_buffer_size(0)
+ .max_write_buffer_size(max_write_buffer)
+ .max_message_size(MAX_CONTROL_BYTES)
+ .max_frame_size(MAX_CONTROL_BYTES)
+ .on_upgrade(move |socket| {
+ run_session(
+ socket,
+ state,
+ peer,
+ topic,
+ id,
+ admission,
+ session_auth,
+ reliable_policy_epoch,
+ )
+ })
+ .into_response())
+}
+
+fn ensure_origin_allowed(allowed: &[String], headers: &HeaderMap) -> Result<(), ApiError> {
+ let Some(origin) = headers.get(header::ORIGIN) else {
+ return Ok(());
+ };
+ let allowed = origin
+ .to_str()
+ .ok()
+ .is_some_and(|origin| allowed.iter().any(|candidate| candidate == origin));
+ if allowed {
+ Ok(())
+ } else {
+ Err(ApiError {
+ status: StatusCode::FORBIDDEN,
+ code: "E_WS_ORIGIN",
+ detail: "WebSocket Origin is not allowed".into(),
+ })
+ }
+}
+
+async fn authenticate_header(
+ state: &AppState,
+ peer: SocketAddr,
+ topic: &str,
+ headers: &HeaderMap,
+) -> Result {
+ let Some(authenticator) = state.authenticator.as_deref() else {
+ return Ok(SessionAuth {
+ session: None,
+ secret: None,
+ });
+ };
+ let Some(value) = headers.get(header::AUTHORIZATION) else {
+ return Ok(SessionAuth {
+ session: None,
+ secret: None,
+ });
+ };
+ let secret = value
+ .to_str()
+ .ok()
+ .and_then(|value| value.strip_prefix("Bearer "))
+ .filter(|secret| !secret.is_empty())
+ .ok_or_else(|| {
+ observe_auth_failure(state);
+ ApiError {
+ status: StatusCode::UNAUTHORIZED,
+ code: "E_AUTH_FAILED",
+ detail: "Authorization must use a non-empty Bearer token".into(),
+ }
+ })?
+ .to_owned();
+ let session = authenticate(authenticator, peer, topic, &secret)
+ .await
+ .map_err(|error| auth_api_error(state, error))?;
+ Ok(SessionAuth {
+ session: Some(session),
+ secret: Some(secret),
+ })
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn run_session(
+ mut socket: WebSocket,
+ state: AppState,
+ peer: SocketAddr,
+ topic: String,
+ id: u64,
+ _admission: ConnectionAdmission,
+ mut auth: SessionAuth,
+ reliable_policy_epoch: u64,
+) {
+ state
+ .metrics
+ .websocket_connections
+ .fetch_add(1, Ordering::Relaxed);
+ let result = run_session_inner(
+ &mut socket,
+ &state,
+ peer,
+ &topic,
+ id,
+ &mut auth,
+ reliable_policy_epoch,
+ )
+ .await;
+ state
+ .metrics
+ .websocket_connections
+ .fetch_sub(1, Ordering::Relaxed);
+ if let Err(error) = result {
+ debug!(%peer, %topic, connection_id = id, %error, "WebSocket subscriber disconnected");
+ }
+}
+
+async fn run_session_inner(
+ socket: &mut WebSocket,
+ state: &AppState,
+ peer: SocketAddr,
+ topic: &str,
+ id: u64,
+ auth: &mut SessionAuth,
+ reliable_policy_epoch: u64,
+) -> anyhow::Result<()> {
+ if state.authenticator.is_some() && auth.session.is_none() {
+ send_control(socket, json!({"type": "auth_required"})).await?;
+ match read_auth(socket, state, peer, topic).await {
+ Ok(session) => *auth = session,
+ Err(error) => {
+ observe_auth_failure(state);
+ close_control(socket, error.code, &error.detail, 1008).await;
+ anyhow::bail!(error.detail);
+ }
+ }
+ }
+ ensure_authorized(auth.session.as_ref(), topic)?;
+ let mut policy_changes = match state.broker.subscribe_topic_policy(topic) {
+ Ok(changes) => changes,
+ Err(error) => {
+ handle_broker_error(socket, error).await;
+ anyhow::bail!("Topic became unavailable before subscription readiness");
+ }
+ };
+ if policy_changes.borrow().delivery_mode != DeliveryMode::TtlDiscard {
+ close_control(
+ socket,
+ "E_WS_TTL_REQUIRED",
+ "Topic no longer uses TTL_DISCARD",
+ 1008,
+ )
+ .await;
+ anyhow::bail!("Topic stopped using TTL_DISCARD before subscription readiness");
+ }
+ let channel: Arc = Arc::from(format!("ws.{}.{}#ephemeral", state.config.node.id, id));
+ let cleanup = match EphemeralCleanup::register(
+ state.ephemeral_consumers.clone(),
+ Arc::clone(&state.broker),
+ topic,
+ channel.as_ref(),
+ )
+ .await
+ {
+ Ok(cleanup) => cleanup,
+ Err(error) => {
+ let (code, close) = match &error {
+ BrokerError::ChannelLimit => {
+ state
+ .metrics
+ .websocket_capacity_rejections
+ .fetch_add(1, Ordering::Relaxed);
+ ("E_WS_CAPACITY", 1013)
+ }
+ BrokerError::TopicNotFound
+ | BrokerError::TopicRetiring
+ | BrokerError::TopicTombstoned => ("E_TOPIC_CLOSED", 1001),
+ BrokerError::ManagementUnavailable => ("E_OWNER_CHANGED", 1012),
+ _ => ("E_BROKER", 1011),
+ };
+ close_control(socket, code, &error.to_string(), close).await;
+ return Err(error.into());
+ }
+ };
+ if policy_changes.has_changed().is_err() {
+ close_control(
+ socket,
+ "E_TOPIC_CLOSED",
+ "Topic was removed while creating the live Channel",
+ 1001,
+ )
+ .await;
+ anyhow::bail!("Topic was removed while creating the live Channel");
+ }
+ let current_policy = match state.broker.topic_policy(topic) {
+ Ok(policy) => policy,
+ Err(error) => {
+ handle_broker_error(socket, error).await;
+ anyhow::bail!("Topic became unavailable while creating the live Channel");
+ }
+ };
+ if current_policy.delivery_mode != DeliveryMode::TtlDiscard {
+ close_control(
+ socket,
+ "E_WS_TTL_REQUIRED",
+ "Topic no longer uses TTL_DISCARD",
+ 1008,
+ )
+ .await;
+ anyhow::bail!("Topic stopped using TTL_DISCARD while creating the live Channel");
+ }
+ if reliable_policy_changed(state, topic, reliable_policy_epoch)? {
+ close_control(
+ socket,
+ "E_WS_TTL_REQUIRED",
+ "Topic switched to RELIABLE before subscription readiness",
+ 1008,
+ )
+ .await;
+ anyhow::bail!("Topic switched to RELIABLE before subscription readiness");
+ }
+ if state.broker.topic_policy(topic)?.delivery_mode != DeliveryMode::TtlDiscard {
+ close_control(
+ socket,
+ "E_WS_TTL_REQUIRED",
+ "Topic no longer uses TTL_DISCARD",
+ 1008,
+ )
+ .await;
+ anyhow::bail!("Topic stopped using TTL_DISCARD before subscription readiness");
+ }
+ let identity = ClientIdentity {
+ client_id: format!("ws-{id}"),
+ hostname: "websocket".into(),
+ remote_address: peer.to_string(),
+ user_agent: "rustqueue.live.v1".into(),
+ authed: auth.session.is_some(),
+ ..ClientIdentity::default()
+ };
+ let lease = state
+ .subscriptions
+ .register(topic, channel.as_ref(), identity)
+ .map_err(|_| anyhow::anyhow!("subscription deletion is in progress"))?;
+ lease.update_flow(1, 0);
+ send_control(
+ socket,
+ json!({
+ "type": "ready",
+ "topic": topic,
+ "subscription_id": id.to_string(),
+ "start": "tail",
+ "ack": "auto"
+ }),
+ )
+ .await?;
+
+ let mut shutdown = state.shutdown.clone();
+ let heartbeat = Duration::from_millis(state.config.limits.heartbeat_interval_ms);
+ let mut heartbeat_tick = tokio::time::interval(heartbeat);
+ heartbeat_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
+ heartbeat_tick.tick().await;
+ let mut last_peer_activity = Instant::now();
+ loop {
+ if !state.delivering.load(Ordering::Acquire) {
+ close_control(socket, "E_DRAINING", "Broker is draining", 1001).await;
+ break;
+ }
+ if reliable_policy_changed(state, topic, reliable_policy_epoch)? {
+ close_control(
+ socket,
+ "E_WS_TTL_REQUIRED",
+ "Topic switched to RELIABLE",
+ 1008,
+ )
+ .await;
+ break;
+ }
+ if let Err(error) = refresh_auth(auth, state, peer, topic).await {
+ close_control(socket, error.code, &error.detail, 1008).await;
+ anyhow::bail!(error.detail);
+ }
+ let fetch = state.broker.fetch_batch_retained(
+ topic,
+ channel.as_ref(),
+ 1,
+ state.config.queue.max_message_bytes,
+ Duration::from_secs(1),
+ Some(
+ state
+ .config
+ .message_timeout()
+ .saturating_add(Duration::from_millis(
+ state.config.limits.tcp_command_timeout_ms,
+ )),
+ ),
+ );
+ tokio::pin!(fetch);
+ let batch = tokio::select! {
+ biased;
+ changed = shutdown.changed() => {
+ let _ = changed;
+ close_control(socket, "E_SHUTDOWN", "Broker is shutting down", 1001).await;
+ break;
+ }
+ changed = policy_changes.changed() => {
+ if changed.is_err() {
+ close_control(socket, "E_TOPIC_CLOSED", "Topic was removed", 1001).await;
+ break;
+ }
+ if policy_changes.borrow_and_update().delivery_mode != DeliveryMode::TtlDiscard {
+ close_control(socket, "E_WS_TTL_REQUIRED", "Topic no longer uses TTL_DISCARD", 1008).await;
+ break;
+ }
+ continue;
+ }
+ incoming = socket.recv() => {
+ if !handle_incoming(socket, incoming, &mut last_peer_activity).await? {
+ break;
+ }
+ continue;
+ }
+ _ = heartbeat_tick.tick() => {
+ if last_peer_activity.elapsed() >= heartbeat.saturating_mul(2) {
+ state.metrics.websocket_slow_disconnects.fetch_add(1, Ordering::Relaxed);
+ close_control(socket, "E_WS_HEARTBEAT", "WebSocket heartbeat timed out", 1008).await;
+ break;
+ }
+ let ping = tokio::time::timeout(
+ Duration::from_millis(state.config.limits.tcp_command_timeout_ms),
+ socket.send(Message::Ping(bytes::Bytes::new())),
+ )
+ .await;
+ match ping {
+ Ok(result) => result?,
+ Err(_) => {
+ state.metrics.websocket_slow_disconnects.fetch_add(1, Ordering::Relaxed);
+ anyhow::bail!("WebSocket heartbeat write timed out");
+ }
+ }
+ continue;
+ }
+ result = &mut fetch => match result {
+ Ok(batch) => batch,
+ Err(error) => {
+ handle_broker_error(socket, error).await;
+ break;
+ }
+ },
+ };
+ let (mut deliveries, mut guard) = batch.into_parts();
+ let Some(delivery) = deliveries.pop() else {
+ continue;
+ };
+ lease.update_flow(0, 1);
+ let outcome = deliver_one(
+ socket,
+ state,
+ topic,
+ &channel,
+ delivery,
+ &mut guard,
+ &mut policy_changes,
+ reliable_policy_epoch,
+ )
+ .await;
+ lease.update_flow(1, 0);
+ match outcome {
+ Ok(true) => {
+ lease.observe_delivery();
+ lease.observe_finish();
+ }
+ Ok(false) => continue,
+ Err(error) => {
+ return Err(error);
+ }
+ }
+ }
+ drop(lease);
+ drop(cleanup);
+ Ok(())
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn deliver_one(
+ socket: &mut WebSocket,
+ state: &AppState,
+ topic: &str,
+ channel: &Arc,
+ delivery: Delivery,
+ guard: &mut DeliveryGuard,
+ policy_changes: &mut tokio::sync::watch::Receiver,
+ reliable_policy_epoch: u64,
+) -> anyhow::Result {
+ let Some(expiration_ns) = state
+ .broker
+ .delivery_expiration_ns(topic, delivery.timestamp_ns)?
+ else {
+ anyhow::bail!("Topic stopped using TTL_DISCARD");
+ };
+ if remaining_until(expiration_ns).is_zero() {
+ return Ok(false);
+ }
+ let frame_bytes = FRAME_HEADER_BYTES.saturating_add(delivery.body.len());
+ let Some(_frame_hold) = state.websocket.reserve_frame(frame_bytes) else {
+ state
+ .metrics
+ .websocket_capacity_rejections
+ .fetch_add(1, Ordering::Relaxed);
+ close_control(
+ socket,
+ "E_WS_CAPACITY",
+ "WebSocket frame memory capacity is exhausted",
+ 1013,
+ )
+ .await;
+ anyhow::bail!("WebSocket frame memory capacity is exhausted");
+ };
+ let frame = encode_delivery(&delivery);
+ let write_deadline = Instant::now()
+ .checked_add(Duration::from_millis(
+ state.config.limits.tcp_command_timeout_ms,
+ ))
+ .unwrap_or_else(Instant::now);
+ let send = socket.send(Message::Binary(frame));
+ tokio::pin!(send);
+ let mut shutdown = state.shutdown.clone();
+ loop {
+ let expiration_ns = state
+ .broker
+ .delivery_expiration_ns(topic, delivery.timestamp_ns)?
+ .ok_or_else(|| anyhow::anyhow!("Topic stopped using TTL_DISCARD"))?;
+ let ttl_remaining = remaining_until(expiration_ns);
+ let socket_remaining = write_deadline.saturating_duration_since(Instant::now());
+ if ttl_remaining.is_zero() {
+ anyhow::bail!("Message TTL expired during WebSocket frame write");
+ }
+ if socket_remaining.is_zero() {
+ state
+ .metrics
+ .websocket_slow_disconnects
+ .fetch_add(1, Ordering::Relaxed);
+ anyhow::bail!("WebSocket frame write timed out");
+ }
+ let timeout = ttl_remaining.min(socket_remaining);
+ tokio::select! {
+ biased;
+ changed = shutdown.changed() => {
+ let _ = changed;
+ anyhow::bail!("Broker shut down during WebSocket frame write");
+ }
+ changed = policy_changes.changed() => {
+ if changed.is_err() {
+ anyhow::bail!("Topic closed during WebSocket frame write");
+ }
+ if reliable_policy_changed(state, topic, reliable_policy_epoch)? {
+ anyhow::bail!("Topic switched to RELIABLE during WebSocket frame write");
+ }
+ if policy_changes.borrow_and_update().delivery_mode != DeliveryMode::TtlDiscard {
+ anyhow::bail!("Topic stopped using TTL_DISCARD during WebSocket frame write");
+ }
+ continue;
+ }
+ result = &mut send => {
+ result?;
+ break;
+ }
+ _ = tokio::time::sleep(timeout) => {
+ if socket_remaining <= ttl_remaining {
+ state.metrics.websocket_slow_disconnects.fetch_add(1, Ordering::Relaxed);
+ anyhow::bail!("WebSocket frame write timed out");
+ }
+ anyhow::bail!("Message TTL expired during WebSocket frame write");
+ }
+ }
+ }
+ let Some(token) = guard.accept_with_token(delivery.id) else {
+ anyhow::bail!("WebSocket delivery reservation disappeared");
+ };
+ state
+ .broker
+ .finish_delivery_shared(topic, Arc::clone(channel), delivery.id, token)
+ .await?;
+ state
+ .metrics
+ .websocket_messages
+ .fetch_add(1, Ordering::Relaxed);
+ state
+ .metrics
+ .websocket_bytes
+ .fetch_add(delivery.body.len() as u64, Ordering::Relaxed);
+ Ok(true)
+}
+
+fn reliable_policy_changed(
+ state: &AppState,
+ topic: &str,
+ expected_epoch: u64,
+) -> Result {
+ Ok(state.broker.topic_reliable_policy_epoch(topic)? != expected_epoch)
+}
+
+fn encode_delivery(delivery: &Delivery) -> bytes::Bytes {
+ let mut frame = BytesMut::with_capacity(FRAME_HEADER_BYTES + delivery.body.len());
+ frame.extend_from_slice(FRAME_MAGIC);
+ frame.put_u16(FRAME_HEADER_BYTES as u16);
+ frame.put_u16(delivery.attempts);
+ frame.put_i64(delivery.timestamp_ns);
+ frame.put_u64(delivery.id);
+ frame.extend_from_slice(&delivery.body);
+ frame.freeze()
+}
+
+async fn read_auth(
+ socket: &mut WebSocket,
+ state: &AppState,
+ peer: SocketAddr,
+ topic: &str,
+) -> Result {
+ let message = tokio::time::timeout(
+ Duration::from_millis(state.config.limits.auth_timeout_ms),
+ socket.recv(),
+ )
+ .await
+ .map_err(|_| ControlAuthError {
+ code: "E_AUTH_TIMEOUT",
+ detail: "WebSocket AUTH timed out".into(),
+ })?
+ .ok_or_else(|| ControlAuthError {
+ code: "E_AUTH_FAILED",
+ detail: "WebSocket closed before AUTH".into(),
+ })?
+ .map_err(|error| ControlAuthError {
+ code: "E_AUTH_FAILED",
+ detail: error.to_string(),
+ })?;
+ let Message::Text(text) = message else {
+ return Err(ControlAuthError {
+ code: "E_BAD_AUTH",
+ detail: "WebSocket AUTH must be a text control frame".into(),
+ });
+ };
+ let request: AuthControl =
+ serde_json::from_str(text.as_str()).map_err(|error| ControlAuthError {
+ code: "E_BAD_AUTH",
+ detail: error.to_string(),
+ })?;
+ if request.kind != "auth" || request.secret.is_empty() {
+ return Err(ControlAuthError {
+ code: "E_BAD_AUTH",
+ detail: "WebSocket AUTH frame is invalid".into(),
+ });
+ }
+ let authenticator = state
+ .authenticator
+ .as_deref()
+ .ok_or_else(|| ControlAuthError {
+ code: "E_AUTH_DISABLED",
+ detail: "WebSocket AUTH is disabled".into(),
+ })?;
+ let session = authenticate(authenticator, peer, topic, &request.secret)
+ .await
+ .map_err(|error| ControlAuthError {
+ code: auth_error_code(&error),
+ detail: "WebSocket AUTH failed".into(),
+ })?;
+ Ok(SessionAuth {
+ session: Some(session),
+ secret: Some(request.secret),
+ })
+}
+
+async fn refresh_auth(
+ auth: &mut SessionAuth,
+ state: &AppState,
+ peer: SocketAddr,
+ topic: &str,
+) -> Result<(), ControlAuthError> {
+ let Some(session) = auth.session.as_ref() else {
+ return Ok(());
+ };
+ if !session.is_expired() {
+ ensure_authorized(Some(session), topic).map_err(|_| ControlAuthError {
+ code: "E_UNAUTHORIZED",
+ detail: "WebSocket subscription is no longer authorized".into(),
+ })?;
+ return Ok(());
+ }
+ let authenticator = state
+ .authenticator
+ .as_deref()
+ .ok_or_else(|| ControlAuthError {
+ code: "E_AUTH_FAILED",
+ detail: "WebSocket AUTH configuration is unavailable".into(),
+ })?;
+ let secret = auth.secret.as_deref().ok_or_else(|| ControlAuthError {
+ code: "E_AUTH_FAILED",
+ detail: "WebSocket AUTH state is unavailable".into(),
+ })?;
+ let session = authenticate(authenticator, peer, topic, secret)
+ .await
+ .map_err(|error| {
+ observe_auth_failure(state);
+ ControlAuthError {
+ code: auth_error_code(&error),
+ detail: "WebSocket AUTH refresh failed".into(),
+ }
+ })?;
+ auth.session = Some(session);
+ Ok(())
+}
+
+async fn authenticate(
+ authenticator: &Authenticator,
+ peer: SocketAddr,
+ topic: &str,
+ secret: &str,
+) -> Result {
+ let session = authenticator
+ .authenticate(&peer.ip().to_string(), false, "", secret.as_bytes())
+ .await?;
+ if !session.can_subscribe(topic, AUTH_CHANNEL) {
+ return Err(AuthError::Unauthorized);
+ }
+ Ok(session)
+}
+
+fn ensure_authorized(session: Option<&AuthSession>, topic: &str) -> anyhow::Result<()> {
+ if session.is_some_and(|session| !session.can_subscribe(topic, AUTH_CHANNEL)) {
+ anyhow::bail!("WebSocket subscription is not authorized");
+ }
+ Ok(())
+}
+
+fn auth_api_error(state: &AppState, error: AuthError) -> ApiError {
+ observe_auth_failure(state);
+ let (status, code, detail) = match error {
+ AuthError::Unauthorized => (
+ StatusCode::FORBIDDEN,
+ "E_UNAUTHORIZED",
+ "AUTH does not permit this Topic subscription",
+ ),
+ AuthError::Overloaded => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ "E_AUTH_OVERLOADED",
+ "AUTH capacity is exhausted",
+ ),
+ _ => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ "E_AUTH_FAILED",
+ "AUTH service failed",
+ ),
+ };
+ ApiError {
+ status,
+ code,
+ detail: detail.into(),
+ }
+}
+
+fn observe_auth_failure(state: &AppState) {
+ state
+ .metrics
+ .websocket_auth_failures
+ .fetch_add(1, Ordering::Relaxed);
+ state.metrics.auth_failures.fetch_add(1, Ordering::Relaxed);
+}
+
+fn auth_error_code(error: &AuthError) -> &'static str {
+ match error {
+ AuthError::Unauthorized => "E_UNAUTHORIZED",
+ AuthError::Overloaded => "E_AUTH_OVERLOADED",
+ _ => "E_AUTH_FAILED",
+ }
+}
+
+async fn handle_incoming(
+ socket: &mut WebSocket,
+ incoming: Option>,
+ last_peer_activity: &mut Instant,
+) -> anyhow::Result {
+ let Some(message) = incoming else {
+ return Ok(false);
+ };
+ let message = message?;
+ *last_peer_activity = Instant::now();
+ match message {
+ Message::Close(_) => Ok(false),
+ Message::Ping(_) => Ok(true),
+ Message::Pong(_) => Ok(true),
+ Message::Text(_) | Message::Binary(_) => {
+ close_control(socket, "E_WS_CONTROL", "Unexpected client data frame", 1008).await;
+ Ok(false)
+ }
+ }
+}
+
+async fn handle_broker_error(socket: &mut WebSocket, error: BrokerError) {
+ let (code, close) = match &error {
+ BrokerError::TopicNotFound | BrokerError::TopicRetiring | BrokerError::TopicTombstoned => {
+ ("E_TOPIC_CLOSED", 1001)
+ }
+ BrokerError::ManagementUnavailable => ("E_OWNER_CHANGED", 1012),
+ _ => ("E_BROKER", 1011),
+ };
+ close_control(socket, code, &error.to_string(), close).await;
+}
+
+async fn send_control(socket: &mut WebSocket, value: Value) -> anyhow::Result<()> {
+ tokio::time::timeout(
+ CONTROL_WRITE_TIMEOUT,
+ socket.send(Message::Text(serde_json::to_string(&value)?.into())),
+ )
+ .await
+ .map_err(|_| anyhow::anyhow!("WebSocket control write timed out"))??;
+ Ok(())
+}
+
+async fn close_control(socket: &mut WebSocket, code: &'static str, detail: &str, close_code: u16) {
+ let _ = tokio::time::timeout(CONTROL_WRITE_TIMEOUT, async {
+ send_control(
+ socket,
+ json!({"type": "error", "code": code, "detail": detail}),
+ )
+ .await?;
+ socket
+ .send(Message::Close(Some(CloseFrame {
+ code: close_code,
+ reason: Utf8Bytes::from_static(code),
+ })))
+ .await?;
+ anyhow::Ok(())
+ })
+ .await;
+}
+
+fn remaining_until(deadline_ns: i64) -> Duration {
+ let remaining = deadline_ns.saturating_sub(now_ns());
+ Duration::from_nanos(remaining.max(0) as u64)
+}
+
+fn now_ns() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos()
+ .min(i64::MAX as u128) as i64
+}
+
+struct EphemeralCleanup {
+ consumers: EphemeralConsumers,
+ broker: Arc,
+ topic: String,
+ channel: String,
+}
+
+impl EphemeralCleanup {
+ async fn register(
+ consumers: EphemeralConsumers,
+ broker: Arc,
+ topic: &str,
+ channel: &str,
+ ) -> Result {
+ consumers.register_existing(&broker, topic, channel).await?;
+ Ok(Self {
+ consumers,
+ broker,
+ topic: topic.into(),
+ channel: channel.into(),
+ })
+ }
+}
+
+impl Drop for EphemeralCleanup {
+ fn drop(&mut self) {
+ let consumers = self.consumers.clone();
+ let broker = Arc::clone(&self.broker);
+ let topic = self.topic.clone();
+ let channel = self.channel.clone();
+ tokio::spawn(async move {
+ consumers.unregister(&broker, &topic, &channel).await;
+ });
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn runtime(max_connections: usize, max_connections_per_topic: usize) -> WebSocketRuntime {
+ let config = crate::config::WebSocketConfig {
+ max_connections,
+ max_connections_per_topic,
+ ..Default::default()
+ };
+ WebSocketRuntime::new(&config)
+ }
+
+ #[test]
+ fn encodes_rqw1_golden_frame() {
+ let delivery = Delivery {
+ id: 0x0102_0304_0506_0708,
+ timestamp_ns: -0x0102_0304_0506_0708,
+ attempts: 0x090a,
+ body: Arc::<[u8]>::from(&b"hello"[..]),
+ };
+
+ let frame = encode_delivery(&delivery);
+
+ assert_eq!(
+ frame.as_ref(),
+ &[
+ b'R', b'Q', b'W', b'1', 0, 24, 9, 10, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa, 0xf9, 0xf8,
+ 0xf8, 1, 2, 3, 4, 5, 6, 7, 8, b'h', b'e', b'l', b'l', b'o',
+ ]
+ );
+ }
+
+ #[test]
+ fn admission_enforces_global_and_per_topic_limits_and_releases() {
+ let runtime = runtime(2, 1);
+ let (_, first) = runtime.admit("events").unwrap();
+ assert!(matches!(
+ runtime.admit("events"),
+ Err(AdmissionError::Topic)
+ ));
+ let (_, second) = runtime.admit("other").unwrap();
+ assert!(matches!(
+ runtime.admit("third"),
+ Err(AdmissionError::Global)
+ ));
+
+ drop(first);
+ let (_, replacement) = runtime.admit("events").unwrap();
+ assert!(matches!(
+ runtime.admit("other"),
+ Err(AdmissionError::Global)
+ ));
+
+ drop(second);
+ drop(replacement);
+ assert!(runtime.admit("events").is_ok());
+ }
+
+ #[test]
+ fn origin_check_allows_missing_and_exact_origin_only() {
+ let allowed = ["https://app.example".to_owned()];
+ let missing = HeaderMap::new();
+ assert!(ensure_origin_allowed(&allowed, &missing).is_ok());
+
+ let mut exact = HeaderMap::new();
+ exact.insert(
+ header::ORIGIN,
+ HeaderValue::from_static("https://app.example"),
+ );
+ assert!(ensure_origin_allowed(&allowed, &exact).is_ok());
+
+ let mut near_miss = HeaderMap::new();
+ near_miss.insert(
+ header::ORIGIN,
+ HeaderValue::from_static("https://app.example.evil"),
+ );
+ let error = ensure_origin_allowed(&allowed, &near_miss).unwrap_err();
+ assert_eq!(error.status, StatusCode::FORBIDDEN);
+ assert_eq!(error.code, "E_WS_ORIGIN");
+ }
+}
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs
index a9a891b..94c5ba7 100644
--- a/crates/server/src/main.rs
+++ b/crates/server/src/main.rs
@@ -11,6 +11,7 @@ mod tls;
use admission::PublishAdmission;
use anyhow::Context;
+use auth::Authenticator;
use clap::Parser;
use config::Config;
use metrics::Metrics;
@@ -106,6 +107,10 @@ async fn main() -> anyhow::Result<()> {
Arc::clone(&metrics),
));
let subscriptions = SubscriptionRegistry::default();
+ let authenticator = Authenticator::new(&config)
+ .map_err(anyhow::Error::msg)?
+ .map(Arc::new);
+ let ephemeral_consumers = tcp::EphemeralConsumers::default();
let initially_pressured = disk_guard::initialize(&config, &publish_admission, &metrics)?;
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
@@ -125,6 +130,8 @@ async fn main() -> anyhow::Result<()> {
Arc::clone(&delivering),
Arc::clone(&publish_admission),
subscriptions.clone(),
+ authenticator.clone(),
+ ephemeral_consumers.clone(),
shutdown_rx.clone(),
std::time::Duration::from_secs(config.shutdown.grace_seconds)
.saturating_sub(std::time::Duration::from_millis(250)),
@@ -137,6 +144,8 @@ async fn main() -> anyhow::Result<()> {
Arc::clone(&delivering),
Arc::clone(&publish_admission),
subscriptions.clone(),
+ authenticator,
+ ephemeral_consumers,
shutdown_rx.clone(),
));
let mut kodo_http_task = tokio::spawn(http::serve_kodo_compat(
diff --git a/crates/server/src/metrics.rs b/crates/server/src/metrics.rs
index d11e48b..a53c360 100644
--- a/crates/server/src/metrics.rs
+++ b/crates/server/src/metrics.rs
@@ -7,6 +7,13 @@ use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
#[derive(Default)]
pub struct Metrics {
pub tcp_connections: AtomicI64,
+ pub websocket_connections: AtomicI64,
+ pub websocket_messages: AtomicU64,
+ pub websocket_bytes: AtomicU64,
+ pub websocket_auth_failures: AtomicU64,
+ pub websocket_capacity_rejections: AtomicU64,
+ pub websocket_slow_disconnects: AtomicU64,
+ pub websocket_non_ttl_rejections: AtomicU64,
pub publish_messages: AtomicU64,
pub publish_bytes: AtomicU64,
pub publish_inflight_bytes: AtomicI64,
@@ -36,6 +43,13 @@ pub struct Metrics {
#[derive(Clone, Debug, Serialize)]
pub struct RuntimeMetricsSnapshot {
pub tcp_connections: i64,
+ pub websocket_connections: i64,
+ pub websocket_messages: u64,
+ pub websocket_bytes: u64,
+ pub websocket_auth_failures: u64,
+ pub websocket_capacity_rejections: u64,
+ pub websocket_slow_disconnects: u64,
+ pub websocket_non_ttl_rejections: u64,
pub publish_messages: u64,
pub publish_bytes: u64,
pub publish_inflight_bytes: i64,
@@ -300,6 +314,15 @@ impl Metrics {
pub fn snapshot(&self) -> RuntimeMetricsSnapshot {
RuntimeMetricsSnapshot {
tcp_connections: self.tcp_connections.load(Ordering::Relaxed),
+ websocket_connections: self.websocket_connections.load(Ordering::Relaxed),
+ websocket_messages: self.websocket_messages.load(Ordering::Relaxed),
+ websocket_bytes: self.websocket_bytes.load(Ordering::Relaxed),
+ websocket_auth_failures: self.websocket_auth_failures.load(Ordering::Relaxed),
+ websocket_capacity_rejections: self
+ .websocket_capacity_rejections
+ .load(Ordering::Relaxed),
+ websocket_slow_disconnects: self.websocket_slow_disconnects.load(Ordering::Relaxed),
+ websocket_non_ttl_rejections: self.websocket_non_ttl_rejections.load(Ordering::Relaxed),
publish_messages: self.publish_messages.load(Ordering::Relaxed),
publish_bytes: self.publish_bytes.load(Ordering::Relaxed),
publish_inflight_bytes: self.publish_inflight_bytes.load(Ordering::Relaxed),
@@ -332,6 +355,20 @@ impl Metrics {
concat!(
"# TYPE rustqueue_tcp_connections gauge\n",
"rustqueue_tcp_connections {}\n",
+ "# TYPE rustqueue_websocket_connections gauge\n",
+ "rustqueue_websocket_connections {}\n",
+ "# TYPE rustqueue_websocket_messages_total counter\n",
+ "rustqueue_websocket_messages_total {}\n",
+ "# TYPE rustqueue_websocket_bytes_total counter\n",
+ "rustqueue_websocket_bytes_total {}\n",
+ "# TYPE rustqueue_websocket_auth_failures_total counter\n",
+ "rustqueue_websocket_auth_failures_total {}\n",
+ "# TYPE rustqueue_websocket_capacity_rejections_total counter\n",
+ "rustqueue_websocket_capacity_rejections_total {}\n",
+ "# TYPE rustqueue_websocket_slow_disconnects_total counter\n",
+ "rustqueue_websocket_slow_disconnects_total {}\n",
+ "# TYPE rustqueue_websocket_non_ttl_rejections_total counter\n",
+ "rustqueue_websocket_non_ttl_rejections_total {}\n",
"# TYPE rustqueue_publish_messages_total counter\n",
"rustqueue_publish_messages_total {}\n",
"# TYPE rustqueue_publish_bytes_total counter\n",
@@ -382,6 +419,13 @@ impl Metrics {
"rustqueue_protective_evicted_messages_total {}\n"
),
self.tcp_connections.load(Ordering::Relaxed),
+ self.websocket_connections.load(Ordering::Relaxed),
+ self.websocket_messages.load(Ordering::Relaxed),
+ self.websocket_bytes.load(Ordering::Relaxed),
+ self.websocket_auth_failures.load(Ordering::Relaxed),
+ self.websocket_capacity_rejections.load(Ordering::Relaxed),
+ self.websocket_slow_disconnects.load(Ordering::Relaxed),
+ self.websocket_non_ttl_rejections.load(Ordering::Relaxed),
self.publish_messages.load(Ordering::Relaxed),
self.publish_bytes.load(Ordering::Relaxed),
self.publish_inflight_bytes.load(Ordering::Relaxed),
diff --git a/crates/server/src/tcp.rs b/crates/server/src/tcp.rs
index 68c898c..694b7ab 100644
--- a/crates/server/src/tcp.rs
+++ b/crates/server/src/tcp.rs
@@ -13,7 +13,7 @@ use channel_ops::*;
use codec::*;
use commands::*;
use dead_letter::*;
-use ephemeral::*;
+pub(crate) use ephemeral::EphemeralConsumers;
use session::*;
use time::*;
use writer::*;
@@ -224,16 +224,14 @@ pub async fn serve(
delivering: Arc,
publish_admission: Arc,
subscriptions: SubscriptionRegistry,
+ authenticator: Option>,
+ ephemeral_consumers: EphemeralConsumers,
mut shutdown: watch::Receiver,
shutdown_grace: Duration,
) -> anyhow::Result<()> {
let listener = TcpListener::bind(config.network.tcp_address).await?;
let tls_acceptor = tls::acceptor(config.security.tls.as_ref())?;
- let authenticator = Authenticator::new(&config)
- .map_err(anyhow::Error::msg)?
- .map(Arc::new);
let permits = Arc::new(Semaphore::new(config.limits.max_connections));
- let ephemeral_consumers = EphemeralConsumers::default();
info!(address = %config.network.tcp_address, "NSQ TCP listener ready");
let mut sessions = JoinSet::new();
diff --git a/crates/server/src/tcp/ephemeral.rs b/crates/server/src/tcp/ephemeral.rs
index 6f4a0d3..8d00a18 100644
--- a/crates/server/src/tcp/ephemeral.rs
+++ b/crates/server/src/tcp/ephemeral.rs
@@ -1,16 +1,35 @@
use super::*;
#[derive(Clone, Default)]
-pub(super) struct EphemeralConsumers {
+pub(crate) struct EphemeralConsumers {
counts: Arc>>,
}
impl EphemeralConsumers {
- pub async fn register(
+ pub(crate) async fn register(
&self,
broker: &Broker,
topic: &str,
channel: &str,
+ ) -> Result<(), BrokerError> {
+ self.register_inner(broker, topic, channel, true).await
+ }
+
+ pub(crate) async fn register_existing(
+ &self,
+ broker: &Broker,
+ topic: &str,
+ channel: &str,
+ ) -> Result<(), BrokerError> {
+ self.register_inner(broker, topic, channel, false).await
+ }
+
+ async fn register_inner(
+ &self,
+ broker: &Broker,
+ topic: &str,
+ channel: &str,
+ create_topic: bool,
) -> Result<(), BrokerError> {
let counts = Arc::clone(&self.counts);
let broker = broker.clone();
@@ -19,7 +38,11 @@ impl EphemeralConsumers {
let (reply, result) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let mut counts = counts.lock().await;
- let outcome = broker.create_channel(&topic, &channel).await;
+ let outcome = if create_topic {
+ broker.create_channel(&topic, &channel).await
+ } else {
+ broker.create_channel_existing(&topic, &channel).await
+ };
complete_registration(&mut counts, &broker, &topic, &channel, outcome, reply).await;
});
let commit = result
@@ -28,7 +51,7 @@ impl EphemeralConsumers {
commit.send(()).map_err(|_| BrokerError::StorageUnavailable)
}
- pub async fn unregister(&self, broker: &Broker, topic: &str, channel: &str) {
+ pub(crate) async fn unregister(&self, broker: &Broker, topic: &str, channel: &str) {
let counts = Arc::clone(&self.counts);
let broker = broker.clone();
let topic = topic.to_owned();
diff --git a/deploy/helm/rustqueue/Chart.yaml b/deploy/helm/rustqueue/Chart.yaml
index 990e63f..38df640 100644
--- a/deploy/helm/rustqueue/Chart.yaml
+++ b/deploy/helm/rustqueue/Chart.yaml
@@ -2,8 +2,8 @@ apiVersion: v2
name: rustqueue
description: Kubernetes-native share-nothing NSQ-compatible message queue
type: application
-version: 0.9.0
-appVersion: "0.9.0"
+version: 0.10.0
+appVersion: "0.10.0"
kubeVersion: ">=1.28.0-0"
keywords:
- messaging
diff --git a/deploy/helm/rustqueue/crds/rustqueue.io_rustqueues.yaml b/deploy/helm/rustqueue/crds/rustqueue.io_rustqueues.yaml
index 9a4fa76..8ed4374 100644
--- a/deploy/helm/rustqueue/crds/rustqueue.io_rustqueues.yaml
+++ b/deploy/helm/rustqueue/crds/rustqueue.io_rustqueues.yaml
@@ -68,6 +68,23 @@ spec:
default: {}
proxyTcpMaxConnectionAgeSeconds: {type: integer, format: int64, minimum: 0, default: 300}
discoveryReplicas: {type: integer, format: int32, minimum: 2, default: 2}
+ websocket:
+ type: object
+ default:
+ enabled: false
+ maxConnections: 1024
+ maxConnectionsPerTopic: 256
+ frameInflightBytes: 134217728
+ allowedOrigins: []
+ properties:
+ enabled: {type: boolean, default: false}
+ maxConnections: {type: integer, format: int64, minimum: 1, default: 1024}
+ maxConnectionsPerTopic: {type: integer, format: int64, minimum: 1, default: 256}
+ frameInflightBytes: {type: integer, format: int64, minimum: 25, maximum: 4294967295, default: 134217728}
+ allowedOrigins:
+ type: array
+ default: []
+ items: {type: string, pattern: '^https?://[^/?#,\s]+$'}
kodoCompatibility:
type: object
default:
diff --git a/deploy/helm/rustqueue/templates/NOTES.txt b/deploy/helm/rustqueue/templates/NOTES.txt
index cb9be5b..1630bfc 100644
--- a/deploy/helm/rustqueue/templates/NOTES.txt
+++ b/deploy/helm/rustqueue/templates/NOTES.txt
@@ -10,6 +10,13 @@ Producer endpoints (node-local proxy Service):
Consumer lookup endpoint:
http://{{ .Values.queue.name }}-discovery.{{ .Release.Namespace }}.svc:4161
+
+WebSocket TTL live subscriptions (when enabled) connect directly to each
+Broker HTTP address returned by Discovery /lookup:
+ ws:///v1/ws/topics/
+ (use wss:// when the Broker HTTP endpoint is served with TLS)
+ Subprotocol: rustqueue.live.v1
+ Only existing TTL_DISCARD Topics are accepted; there is no replay or ACK.
{{- end }}
{{- if and .Values.queue.create .Values.console.enabled }}
RustQueue Console is available inside the cluster at:
diff --git a/deploy/helm/rustqueue/templates/queue.yaml b/deploy/helm/rustqueue/templates/queue.yaml
index cf2010f..a8591d2 100644
--- a/deploy/helm/rustqueue/templates/queue.yaml
+++ b/deploy/helm/rustqueue/templates/queue.yaml
@@ -46,6 +46,13 @@ spec:
maxDetailedMetricSeries: {{ .Values.queue.maxDetailedMetricSeries | int64 }}
consoleManagementEnabled: {{ and .Values.console.enabled .Values.console.management.enabled }}
discoveryReplicas: {{ .Values.queue.discoveryReplicas }}
+ websocket:
+ enabled: {{ .Values.queue.websocket.enabled }}
+ maxConnections: {{ .Values.queue.websocket.maxConnections | int64 }}
+ maxConnectionsPerTopic: {{ .Values.queue.websocket.maxConnectionsPerTopic | int64 }}
+ frameInflightBytes: {{ .Values.queue.websocket.frameInflightBytes | int64 }}
+ allowedOrigins:
+ {{- toYaml .Values.queue.websocket.allowedOrigins | nindent 6 }}
proxyNodeSelector:
{{- toYaml .Values.queue.proxyNodeSelector | nindent 4 }}
proxyTcpMaxConnectionAgeSeconds: {{ .Values.queue.proxyTcpMaxConnectionAgeSeconds | int64 }}
diff --git a/deploy/helm/rustqueue/values.yaml b/deploy/helm/rustqueue/values.yaml
index 0dd8186..8729276 100644
--- a/deploy/helm/rustqueue/values.yaml
+++ b/deploy/helm/rustqueue/values.yaml
@@ -5,7 +5,7 @@ operator:
replicas: 2
image:
repository: rustqueue-operator
- tag: "0.9.0"
+ tag: "0.10.0"
pullPolicy: IfNotPresent
log: rustqueue_operator=info
resources:
@@ -20,7 +20,7 @@ operator:
queue:
create: true
name: rustqueue
- image: rustqueue:0.9.0
+ image: rustqueue:0.10.0
imagePullPolicy: IfNotPresent
minBrokers: 1
maxBrokers: 500
@@ -57,6 +57,12 @@ queue:
registrySecretName: ""
clientTlsSecretName: ""
discoveryReplicas: 2
+ websocket:
+ enabled: false
+ maxConnections: 1024
+ maxConnectionsPerTopic: 256
+ frameInflightBytes: 134217728
+ allowedOrigins: []
proxyNodeSelector: {}
# Rotate opaque TCP tunnels so a small producer pool is redistributed as the
# Broker fleet changes. Set to 0 to keep connections pinned indefinitely.
diff --git a/docs/architecture/share-nothing-v7.md b/docs/architecture/share-nothing-v7.md
index 6c12c5a..c671d37 100644
--- a/docs/architecture/share-nothing-v7.md
+++ b/docs/architecture/share-nothing-v7.md
@@ -1,7 +1,7 @@
# RustQueue format v7 share-nothing architecture
Status: accepted implementation contract
-Target release: 0.9.0
+Target release: 0.10.0
Data format: v7, clean directories only
## 1. Goal
@@ -125,7 +125,109 @@ TTL cannot restore an already expired prefix.
the fleet over time, but it cannot turn 16 streams into 500-way concurrent
disk I/O.
-### 2.5 Ordering and identity
+### 2.5 WebSocket TTL live subscriptions
+
+The Broker HTTP listener optionally exposes a best-effort live subscription for
+short-lived Topics:
+
+```text
+GET /v1/ws/topics/{topic}
+Sec-WebSocket-Protocol: rustqueue.live.v1
+```
+
+The feature is default-off. A handshake succeeds only when the addressed
+Topic already exists locally and its persisted policy is `TTL_DISCARD`; the
+endpoint never creates a Topic, and `RELIABLE` or unknown Topics are rejected
+with a stable error code. One connection represents one Topic and begins at
+the current tail. There is no historical replay, reconnect resume, consumer
+ACK or WebSocket retry.
+
+Clients first query Discovery `/lookup` and then connect directly to every
+healthy Broker owner returned for the Topic. The producer proxy is not a
+WebSocket fan-out layer. During a visible owner migration, clients may union
+the streams and deduplicate using the broker-scoped message ID in the frame.
+Discovery's read-only lookup response uses the same allowed-origin policy as
+the Broker WebSocket endpoint.
+
+Each message is written as one complete binary frame. Its first 24 bytes are
+big-endian metadata, followed by the original body:
+
+| Offset | Size | Field |
+| --- | ---: | --- |
+| `0..4` | 4 | ASCII `RQW1` |
+| `4..6` | 2 | Header length, fixed at `24` |
+| `6..8` | 2 | Delivery attempts |
+| `8..16` | 8 | Broker timestamp, signed `i64` nanoseconds |
+| `16..24` | 8 | Broker-scoped message ID, unsigned `u64` |
+| `24..` | — | Original message body |
+
+Text frames are control messages. `auth_required`, client `auth`, `ready` and
+stable error codes are part of the v1 control contract. Subscription
+authorization reuses the existing AUTH rules with the virtual Channel
+`websocket#ephemeral`. Service clients can send an HTTP
+`Authorization: Bearer` header during upgrade; browser clients must use the
+control exchange and must not put credentials in a query string. A browser
+Origin must exactly match the configured allowlist. A missing Origin is
+allowed for non-browser clients but does not bypass authentication.
+
+The HTTP `101 Switching Protocols` response establishes only the transport.
+The live subscription boundary is the server's `{"type":"ready",...}` control
+frame. No message capture is promised before `ready`, including during the
+AUTH exchange after `auth_required`; an HTTP upgrade is not a capture
+boundary.
+
+Handshake failures reuse the HTTP `ApiError` shape: the stable code is in the
+JSON `message` field and the human-readable explanation is in `detail`. Once
+upgraded, terminal errors use a text control of the form
+`{"type":"error","code":"...","detail":"..."}` before close:
+
+| Code | Meaning |
+| --- | --- |
+| `E_WS_QUERY_FORBIDDEN` | Query parameters are forbidden; use the path and headers. |
+| `E_WS_ORIGIN` | Browser Origin is not an exact configured allowlist entry. |
+| `E_WS_SUBPROTOCOL` | `rustqueue.live.v1` was not negotiated. |
+| `E_BAD_TOPIC` | Topic does not exist on this Broker. |
+| `E_WS_TTL_REQUIRED` | Existing Topic is not `TTL_DISCARD` or changed away from it. |
+| `E_WS_CAPACITY` | Connection, Channel or frame-memory capacity is exhausted. |
+| `E_WS_CONTROL` | Unsupported client text/binary data frame was received. |
+| `E_WS_HEARTBEAT` | Peer missed the heartbeat liveness window. |
+| `E_AUTH_TIMEOUT` | `auth` was not received before the AUTH deadline. |
+| `E_BAD_AUTH` | AUTH control was non-text, malformed, or missing a valid `auth`/`secret`. |
+| `E_AUTH_OVERLOADED` | AUTH service or capacity gate is exhausted; retry later. |
+| `E_AUTH_FAILED` | Authentication service or credential validation failed. |
+| `E_UNAUTHORIZED` | AUTH does not permit the Topic subscription. |
+| `E_TOPIC_CLOSED` | Topic was deleted, tombstoned, or closed. |
+| `E_OWNER_CHANGED` | Topic owner/fence changed; perform a fresh lookup. |
+| `E_DRAINING` | Broker is draining and closes the live subscription. |
+| `E_SHUTDOWN` | Broker is shutting down; reconnect after discovery. |
+
+The delivery path creates one ephemeral Channel per connection and reserves at
+most one message at a time. A reservation is released when the complete frame
+cannot be written, when the connection closes, or when the Broker is shutting
+down; there is no reliable backlog. The write deadline is the smaller of the
+socket timeout and the message's remaining TTL. If a frame cannot finish by
+that deadline, the connection closes rather than leaving a partial frame.
+Paused Topics stop taking messages. Topic deletion, migration, owner shutdown,
+or a change to `RELIABLE` closes affected connections and leaves reconnection
+to the client's next lookup.
+
+Runtime defaults are bounded and opt-in:
+
+```toml
+[websocket]
+enabled = false
+max_connections = 1024
+max_connections_per_topic = 256
+frame_inflight_bytes = 134217728
+allowed_origins = []
+```
+
+The node-wide frame budget and connection caps protect publishers from slow
+consumers. Metrics are aggregate by default and include connection count,
+completed frame messages/bytes, authentication failures, capacity rejections,
+non-TTL Topic rejections and slow-consumer closes.
+
+### 2.6 Ordering and identity
- Storage append order exists only inside one broker/topic log.
- There is no cross-broker order and no global delivery completion order.
@@ -144,6 +246,8 @@ TTL cannot restore an already expired prefix.
- Client TLS/mTLS, AUTH, Snappy, and Deflate.
- Local topic segments and channel WAL/checkpoints.
- Local health, metrics, stats, drain, scrub, and disk-pressure endpoints.
+- Optional best-effort WebSocket delivery for existing `TTL_DISCARD` Topics;
+ it shares the Broker HTTP listener and does not add a listener or port.
- No OpenRaft node, internal replication server, leader routing, peer
discovery, or membership controller.
@@ -169,6 +273,9 @@ Each replica:
`/lookup` returns the healthy owners during a partial Broker outage while
the complete-inventory metric remains false.
+WebSocket clients use `/lookup` only for owner discovery. Discovery does not
+proxy or multiplex the WebSocket data path.
+
Discovery state is derived and is never authoritative message metadata. A
restart reconstructs the complete index from ready brokers. Replicas do not
coordinate and clients may union their answers exactly as with multiple
@@ -366,6 +473,12 @@ move to discovery. Native v6 cluster APIs are removed, including partition,
replica, migration, rebalance, transfer-leader, federation, and cluster
operation resources.
+The optional native Broker endpoint `GET /v1/ws/topics/{topic}` is available
+only when WebSocket support is enabled and the Topic is `TTL_DISCARD`. Clients
+must negotiate `rustqueue.live.v1`; a successful frame write is best effort and
+does not acknowledge application processing. `/v1/capabilities` advertises the
+`websocket_ttl_live_v1` capability when the binary supports this protocol.
+
`/ping` remains process liveness. Broker readiness requires local storage,
clock, and admission health only; it has no cluster-wide dependency.
@@ -373,6 +486,8 @@ clock, and admission health only; it has no cluster-wide dependency.
- The deployment targets a trusted internal Kubernetes network.
- Client-facing broker TLS/mTLS and external AUTH remain optional and compatible.
+- WebSocket Origin checks are exact matches against `websocket.allowed_origins`;
+ the same allowlist is used by Discovery's read-only `/lookup` response.
- Certificates are mounted from user-provided Kubernetes Secrets.
- There is no operator-managed CA.
- Discovery-to-broker polling uses a bounded bearer token, dedicated Service
@@ -478,6 +593,11 @@ The implementation is complete only when all of the following pass:
14. Console management acceptance creates a real three-owner topic, forces the
Console Pod down after one owner is durably recorded, and verifies the new
Pod completes the remaining owners without reapplying the finished owner.
+15. WebSocket acceptance verifies the `rustqueue.live.v1` handshake and 24-byte
+ `RQW1` frame layout, rejects missing and `RELIABLE` Topics without creating
+ them, confirms no replay across connect/disconnect, and exercises AUTH,
+ exact Origin checks, TTL deadline closure, Topic pause/delete/migration,
+ connection and frame-budget rejection, and reservation cleanup.
## 10. Explicit non-goals
@@ -487,5 +607,7 @@ The implementation is complete only when all of the following pass:
- global deduplication, order, channel catalog, or atomic management;
- online broker data migration;
- consumer connection fan-out reduction;
+- reliable WebSocket delivery, replay, resume, client ACK or a shared
+ WebSocket consumer group;
- cross-region replication;
- Broker/PVC lifecycle controls in Console.
diff --git a/docs/operations/kubernetes.md b/docs/operations/kubernetes.md
index e3480e5..e373562 100644
--- a/docs/operations/kubernetes.md
+++ b/docs/operations/kubernetes.md
@@ -71,6 +71,83 @@ kubectl apply -f deploy/helm/rustqueue/crds
kubectl wait --for=condition=Established crd/rustqueuetopics.rustqueue.io --timeout=60s
```
+## WebSocket TTL live subscriptions
+
+The optional WebSocket feed is disabled by default and is available only for
+Topics whose persisted `deliveryMode` is `TTL_DISCARD`. It is served by each
+Broker's existing HTTP listener at:
+
+```text
+GET /v1/ws/topics/{topic}
+Sec-WebSocket-Protocol: rustqueue.live.v1
+```
+
+The endpoint does not create Topics. A missing Topic or a `RELIABLE` Topic is
+rejected during the handshake with a stable error code. One connection covers
+one Topic and starts at the current tail; there is no replay, reconnect resume,
+WebSocket ACK or WebSocket-layer retry. A complete frame write only means that
+RustQueue finished the socket write—it does not mean that the client processed
+the message.
+
+Discover owners first, then connect directly to every Broker HTTP address in
+the Discovery `/lookup` response. Do not send WebSocket traffic through the
+producer proxy or assume that one Broker is a global Topic owner. During a
+visible migration, union the owner streams and deduplicate with the message ID
+in each binary frame. Owner shutdown, migration, Topic deletion, or a change
+to `RELIABLE` closes affected connections; repeat `/lookup` before reconnecting.
+
+Configure the same values in the runtime `[websocket]` block, the Helm
+`queue.websocket` values, and the RustQueue Operator/CRD `websocket` spec:
+
+```toml
+[websocket]
+enabled = false
+max_connections = 1024
+max_connections_per_topic = 256
+frame_inflight_bytes = 134217728
+allowed_origins = []
+```
+
+For example, a deployment can opt in with equivalent Helm fields:
+
+```sh
+helm upgrade rustqueue deploy/helm/rustqueue \
+ --namespace rustqueue --reuse-values \
+ --set queue.websocket.enabled=true \
+ --set queue.websocket.maxConnections=1024 \
+ --set queue.websocket.maxConnectionsPerTopic=256 \
+ --set queue.websocket.frameInflightBytes=134217728
+```
+
+`allowed_origins` is an exact browser Origin allowlist. Browser upgrades with
+an Origin not in the list are rejected; a missing Origin is allowed for a
+service client but still requires normal authentication. Discovery's
+read-only `/lookup` endpoint uses the same allowlist. Service clients can use
+an `Authorization: Bearer` header during upgrade. Browser clients use the
+`auth_required` / `auth` text-control exchange, and credentials must not be
+placed in a query parameter. Authorization reuses the existing subscription
+permission and the virtual Channel `websocket#ephemeral`.
+
+The binary data frame begins with 24 bytes of big-endian metadata followed by
+the raw message body: `RQW1` at `0..4`, header length `24` at `4..6`, attempts
+at `6..8`, signed Broker nanosecond timestamp at `8..16`, and unsigned
+broker-scoped message ID at `16..24`. Text frames carry `auth_required`,
+`ready`, and stable error controls. A client should ignore unknown control
+fields so the protocol can evolve.
+
+Each connection has at most one outstanding reservation. The Broker releases
+the reservation and closes the connection if the frame cannot be completely
+written before the smaller of the socket timeout and remaining message TTL.
+Pause stops new WebSocket deliveries without creating reliable backlog. The
+global frame budget, per-topic and total connection caps are fail-closed; watch
+connection count, frame messages/bytes, authentication failures, capacity
+rejections, non-TTL rejections and slow-consumer closes before raising limits.
+
+The feature reuses the existing Broker HTTP Service and NetworkPolicy. It does
+not add a port or create a default Ingress. If external browser access is
+needed, put the existing HTTP endpoint behind the deployment's TLS and
+authentication boundary and keep the Origin allowlist narrow.
+
## Targeted Broker maintenance
```sh
@@ -101,7 +178,7 @@ Canary approval is optional:
```sh
helm upgrade rustqueue deploy/helm/rustqueue \
--namespace rustqueue \
- --set queue.image=registry.example/rustqueue:0.9.0 \
+ --set queue.image=registry.example/rustqueue:0.10.0 \
--set queue.rollout.requireCanaryApproval=true
rustqueuectl -n rustqueue rollout approve
@@ -113,7 +190,7 @@ Useful controls:
rustqueuectl -n rustqueue rollout pause
rustqueuectl -n rustqueue rollout resume
rustqueuectl -n rustqueue rollout retry
-rustqueuectl -n rustqueue rollout rollback registry.example/rustqueue:0.9.0
+rustqueuectl -n rustqueue rollout rollback registry.example/rustqueue:0.10.0
rustqueuectl -n rustqueue rollout forward
```
diff --git a/docs/releases/v0.10.0.md b/docs/releases/v0.10.0.md
new file mode 100644
index 0000000..2479aad
--- /dev/null
+++ b/docs/releases/v0.10.0.md
@@ -0,0 +1,204 @@
+# RustQueue 0.10.0
+
+RustQueue 0.10.0 adds an optional best-effort WebSocket receive path for live
+consumers of existing `TTL_DISCARD` Topics. Topic TTL discard itself was
+introduced in 0.9.0 and remains the prerequisite; this release does not change
+the NSQ publish contract, reliable Topics, or v7 storage format.
+
+## WebSocket TTL live subscription
+
+When enabled, each Broker's existing HTTP listener accepts one Topic per
+connection:
+
+```text
+GET /v1/ws/topics/{topic}
+Sec-WebSocket-Protocol: rustqueue.live.v1
+```
+
+The Topic must already exist on that Broker and use `deliveryMode=TTL_DISCARD`.
+The endpoint never creates a Topic. Unknown Topics and `RELIABLE` Topics are
+rejected. A client first calls Discovery `/lookup`, then connects directly to
+every healthy Broker owner returned for that Topic; the producer proxy does
+not multiplex this path. A client may union owner streams and deduplicate
+migration overlap using the broker-scoped message ID in each frame.
+
+The stream starts at the current Topic tail. Messages published before the
+connection, during disconnection, or after a failed write are not replayed.
+There is no WebSocket ACK, reconnect resume, or WebSocket-layer retry. A
+complete frame write means only that RustQueue finished the socket write; it is
+not a business processing acknowledgement.
+
+### Ready boundary
+
+The HTTP `101 Switching Protocols` response establishes only the transport.
+The live subscription begins when the server sends the text control frame
+`{"type":"ready",...}`. No message capture is promised before `ready`,
+including while the server has sent `auth_required` and is waiting for the
+client's `auth` control. Clients must not treat HTTP upgrade or
+`auth_required` as the capture boundary.
+
+The `ready` payload identifies the Topic and subscription, and uses
+`{"start":"tail","ack":"auto"}` to make the current-tail and automatic
+post-write completion semantics explicit.
+
+Before `ready`, text controls are used for authentication. After `ready`, the
+server sends complete binary data frames and may send terminal error controls.
+The server may also send WebSocket Ping frames; clients should answer using
+the standard WebSocket Pong behavior.
+
+### Binary data frame
+
+Each message is exactly one complete binary WebSocket frame. The first 24 bytes
+are big-endian metadata followed by the original message body:
+
+| Offset | Size | Field |
+| --- | ---: | --- |
+| `0..4` | 4 | ASCII magic `RQW1` |
+| `4..6` | 2 | Header length, fixed at `24` |
+| `6..8` | 2 | Delivery attempts, unsigned `u16` |
+| `8..16` | 8 | Broker timestamp, signed `i64` nanoseconds |
+| `16..24` | 8 | Broker-scoped message ID, unsigned `u64` |
+| `24..` | — | Original message body |
+
+### AUTH, Origin and errors
+
+WebSocket AUTH reuses the existing subscription permission with the virtual
+Channel `websocket#ephemeral`. Service clients may send
+`Authorization: Bearer ` during the HTTP upgrade. Browser clients can
+complete the `auth_required` / `auth` text-control exchange. Credentials must
+never be placed in a query parameter, and query parameters are rejected.
+
+Browser requests with an `Origin` header must exactly match
+`websocket.allowed_origins`; a missing Origin is accepted for a non-browser
+client but still requires normal authentication. Discovery's read-only
+`/lookup` response uses the same allowed-origin policy.
+
+Handshake failures reuse the HTTP `ApiError` shape: the stable code is in the
+JSON `message` field and the explanation is in `detail`. After upgrade,
+terminal errors use a text control such as
+`{"type":"error","code":"E_TOPIC_CLOSED","detail":"..."}` before the
+connection closes:
+
+| Code | Meaning |
+| --- | --- |
+| `E_WS_QUERY_FORBIDDEN` | Query parameters are forbidden; use the path and headers. |
+| `E_WS_ORIGIN` | Browser Origin is not an exact configured allowlist entry. |
+| `E_WS_SUBPROTOCOL` | `rustqueue.live.v1` was not negotiated. |
+| `E_BAD_TOPIC` | Topic does not exist on this Broker. |
+| `E_WS_TTL_REQUIRED` | Existing Topic is not `TTL_DISCARD` or changed away from it. |
+| `E_WS_CAPACITY` | Connection, Channel or frame-memory capacity is exhausted; back off. |
+| `E_WS_CONTROL` | Unsupported client text/binary data frame was received. |
+| `E_WS_HEARTBEAT` | Peer missed the heartbeat liveness window; reconnect. |
+| `E_AUTH_TIMEOUT` | `auth` was not received before the AUTH deadline. |
+| `E_BAD_AUTH` | AUTH control was non-text, malformed, or missing a valid `auth`/`secret`. |
+| `E_AUTH_OVERLOADED` | AUTH service or capacity gate is exhausted; retry later. |
+| `E_AUTH_FAILED` | Authentication service or credential validation failed. |
+| `E_UNAUTHORIZED` | AUTH does not permit the Topic subscription. |
+| `E_TOPIC_CLOSED` | Topic was deleted, tombstoned, or closed. |
+| `E_OWNER_CHANGED` | Topic owner/fence changed; perform a fresh lookup. |
+| `E_DRAINING` | Broker is draining and closes the live subscription. |
+| `E_SHUTDOWN` | Broker is shutting down; reconnect after discovery. |
+
+### Capacity, TTL and close semantics
+
+The feature is default-off and uses bounded runtime defaults:
+
+```toml
+[websocket]
+enabled = false
+max_connections = 1024
+max_connections_per_topic = 256
+frame_inflight_bytes = 134217728
+allowed_origins = []
+```
+
+Each connection creates one ephemeral Channel and reserves at most one message.
+If the global frame budget, total connection limit, per-Topic limit or Channel
+limit is exhausted, admission fails closed with `E_WS_CAPACITY`. A failed,
+cancelled or timed-out write releases the reservation and closes the
+connection; RustQueue does not retry the frame over WebSocket.
+
+The write deadline is the smaller of the existing socket timeout and the
+message's remaining absolute TTL. If a complete frame cannot be written before
+that deadline, the connection closes rather than leaving a partial frame. TTL
+changes take effect for subsequent deadline checks. Paused Topics stop taking
+messages. Topic deletion, owner migration, a change to `RELIABLE`, Broker
+draining, shutdown and heartbeat timeout close affected connections so the
+client can discover a healthy owner again.
+
+The aggregate metrics include WebSocket connections, completed messages and
+bytes, authentication failures, capacity rejections, slow-consumer closes and
+non-TTL Topic rejections. `/v1/capabilities` advertises
+`websocket_ttl_live_v1` for binaries that implement this protocol.
+
+## Kubernetes configuration
+
+The runtime `[websocket]` settings are exposed equivalently through Helm
+`queue.websocket` values and the RustQueue Operator/CRD `websocket` spec:
+
+```sh
+helm upgrade rustqueue deploy/helm/rustqueue \
+ --namespace rustqueue --reuse-values \
+ --set queue.websocket.enabled=true \
+ --set queue.websocket.maxConnections=1024 \
+ --set queue.websocket.maxConnectionsPerTopic=256 \
+ --set queue.websocket.frameInflightBytes=134217728 \
+ --set-string 'queue.websocket.allowedOrigins[0]=https://app.example'
+```
+
+The equivalent Operator/CRD fragment is:
+
+```yaml
+spec:
+ websocket:
+ enabled: true
+ maxConnections: 1024
+ maxConnectionsPerTopic: 256
+ frameInflightBytes: 134217728
+ allowedOrigins:
+ - https://app.example
+```
+
+The existing Broker HTTP Service and NetworkPolicy are reused. The chart adds
+no port and no default Ingress for this feature. Keep the Origin allowlist
+narrow and put external browser access behind the deployment's normal TLS and
+authentication boundary. Apply the checked-in CRDs before using new WebSocket
+fields on a cluster where the CRDs are already installed; Helm does not
+upgrade an existing CRD automatically.
+
+## Upgrade from 0.9.0
+
+No Topic data migration is required. Existing Topics retain their persisted
+policy, and WebSocket support remains disabled unless explicitly enabled. The
+NSQ TCP/HTTP endpoints, publish acknowledgement modes, Topic TTL semantics and
+v7 storage compatibility remain unchanged.
+
+For a Kubernetes rollout, update the CRDs first, then roll the 0.10.0 image:
+
+```sh
+kubectl apply -f deploy/helm/rustqueue/crds
+kubectl wait --for=condition=Established \
+ crd/rustqueuetopics.rustqueue.io --timeout=60s
+helm upgrade rustqueue deploy/helm/rustqueue \
+ --namespace rustqueue \
+ --set queue.image=registry.example/rustqueue:0.10.0
+```
+
+After the Broker fleet is Ready, enable the `queue.websocket` settings in a
+separate change if live subscriptions are needed. Consumers should continue
+to use `/lookup` and direct owner connections; they must not assume a single
+Broker or a replay window during rollout. A 0.9.0 binary that does not
+advertise `websocket_ttl_live_v1` simply keeps the optional endpoint
+unavailable while the existing NSQ and TTL paths continue to work.
+
+## Release assets
+
+Every 0.10.0 release contains the following five assets:
+
+- `rustqueue-0.10.0-linux-x86_64.tar.gz`: Linux x86_64 binaries, Console UI and
+ example configuration
+- `rustqueue-0.10.0-linux-aarch64.tar.gz`: Linux ARM64 binaries, Console UI and
+ example configuration
+- `rustqueue-0.10.0-source.tar.gz`: source archive for the tagged commit
+- `rustqueue-0.10.0.tgz`: Helm Chart
+- `SHA256SUMS-0.10.0`: SHA-256 checksums for all downloadable assets
diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock
index c2acc47..2a4e745 100644
--- a/fuzz/Cargo.lock
+++ b/fuzz/Cargo.lock
@@ -191,6 +191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
+ "base64",
"bytes",
"form_urlencoded",
"futures-util",
@@ -209,8 +210,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
+ "sha1",
"sync_wrapper",
"tokio",
+ "tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -490,6 +493,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
[[package]]
name = "defmt"
version = "1.1.1"
@@ -970,6 +979,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1473,6 +1488,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1790,10 +1815,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
- "rand_chacha",
+ "rand_chacha 0.3.1",
"rand_core 0.6.4",
]
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand"
version = "0.10.2"
@@ -1815,6 +1850,16 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -1824,6 +1869,15 @@ dependencies = [
"getrandom 0.2.17",
]
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -2017,7 +2071,7 @@ dependencies = [
[[package]]
name = "rustqueue-discovery"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"axum",
@@ -2033,6 +2087,7 @@ dependencies = [
"serde_json",
"sha2",
"tokio",
+ "tower-http",
"tracing",
"tracing-subscriber",
]
@@ -2055,7 +2110,7 @@ dependencies = [
[[package]]
name = "rustqueue-protocol"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"bytes",
"serde",
@@ -2065,7 +2120,7 @@ dependencies = [
[[package]]
name = "rustqueue-proxy"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"axum",
@@ -2085,7 +2140,7 @@ dependencies = [
[[package]]
name = "rustqueue-queue"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"bytes",
@@ -2105,7 +2160,7 @@ dependencies = [
[[package]]
name = "rustqueue-storage"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"anyhow",
"crc32c",
@@ -2117,7 +2172,7 @@ dependencies = [
[[package]]
name = "rustqueue-telemetry"
-version = "0.9.0"
+version = "0.10.0"
dependencies = [
"serde",
]
@@ -2318,6 +2373,17 @@ dependencies = [
"serde",
]
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "sha2"
version = "0.10.9"
@@ -2529,6 +2595,18 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -2569,11 +2647,19 @@ dependencies = [
"base64",
"bitflags 2.13.1",
"bytes",
+ "futures-core",
"futures-util",
"http",
"http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
"mime",
+ "mime_guess",
+ "percent-encoding",
"pin-project-lite",
+ "tokio",
+ "tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -2674,6 +2760,22 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.5",
+ "sha1",
+ "thiserror",
+]
+
[[package]]
name = "typenum"
version = "1.20.1"
@@ -2686,6 +2788,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
diff --git a/rustqueue.example.toml b/rustqueue.example.toml
index ef81feb..789a2ae 100644
--- a/rustqueue.example.toml
+++ b/rustqueue.example.toml
@@ -13,6 +13,15 @@ snappy_enabled = true
deflate_enabled = true
max_deflate_level = 6
+[websocket]
+# Best-effort live subscriptions are opt-in and only accept TTL_DISCARD Topics.
+enabled = false
+max_connections = 1024
+max_connections_per_topic = 256
+frame_inflight_bytes = 134217728
+# Browser Origin values are exact matches; service clients without Origin are allowed.
+allowed_origins = []
+
[storage]
data_path = "/data"
feature_level = 1
diff --git a/scripts/acceptance-multi-broker-k8s.sh b/scripts/acceptance-multi-broker-k8s.sh
index 6473fda..1aa3efe 100755
--- a/scripts/acceptance-multi-broker-k8s.sh
+++ b/scripts/acceptance-multi-broker-k8s.sh
@@ -343,9 +343,9 @@ require jq
}
if [[ "$BUILD_IMAGES" == "1" ]]; then
- BUILD_VERSION=0.9.0-e2e-a MAX_STORAGE_FEATURE_LEVEL=1 make image
+ BUILD_VERSION=0.10.0-e2e-a MAX_STORAGE_FEATURE_LEVEL=1 make image
docker tag rustqueue:dev "$BROKER_IMAGE_A"
- BUILD_VERSION=0.9.0-e2e-b MAX_STORAGE_FEATURE_LEVEL=2 make image-from-dist
+ BUILD_VERSION=0.10.0-e2e-b MAX_STORAGE_FEATURE_LEVEL=2 make image-from-dist
docker tag rustqueue:dev "$BROKER_IMAGE_B"
[[ "$(docker image inspect "$BROKER_IMAGE_A" -f '{{.Id}}')" != \
"$(docker image inspect "$BROKER_IMAGE_B" -f '{{.Id}}')" ]] || {
diff --git a/site/index.html b/site/index.html
index 64efba8..67a4e14 100644
--- a/site/index.html
+++ b/site/index.html
@@ -183,6 +183,10 @@ Every broker owns its queue.
Consumers discover every broker that owns a Topic, then connect directly over the NSQ
protocol.
+
+ TTL_DISCARD Topics also support an optional rustqueue.live.v1 WebSocket
+ live feed: best effort, current-tail only, and direct to each discovered owner.
+
One Helm command.
helm upgrade --install rustqueue deploy/helm/rustqueue \
--namespace rustqueue --create-namespace \
- --set queue.image=registry.example/rustqueue:0.9.0 \
+ --set queue.image=registry.example/rustqueue:0.10.0 \
--set queue.storageClassName=ssd-rwo