diff --git a/Cargo.lock b/Cargo.lock
index c232ae9b67..cd84feae87 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1966,9 +1966,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
-version = "1.2.1"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
@@ -2802,9 +2802,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
-version = "1.0.3"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
@@ -4281,9 +4281,9 @@ dependencies = [
[[package]]
name = "percent-encoding"
-version = "2.3.1"
+version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
@@ -5200,6 +5200,8 @@ dependencies = [
"anyhow",
"axum 0.8.4",
"axum-test",
+ "base64 0.22.1",
+ "ciborium",
"epoxy",
"epoxy-protocol",
"fs_extra",
@@ -6373,6 +6375,7 @@ dependencies = [
"chrono-tz",
"ciborium",
"croner",
+ "ed25519-dalek",
"fs_extra",
"futures",
"getrandom 0.2.16",
@@ -6416,6 +6419,7 @@ dependencies = [
"tracing-opentelemetry",
"tracing-subscriber",
"url",
+ "urlpattern",
"uuid",
"vbare",
"wasm-bindgen",
@@ -8571,14 +8575,15 @@ dependencies = [
[[package]]
name = "url"
-version = "2.5.4"
+version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
+ "serde_derive",
]
[[package]]
@@ -8587,6 +8592,18 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+[[package]]
+name = "urlpattern"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b"
+dependencies = [
+ "icu_properties",
+ "regex",
+ "serde",
+ "url",
+]
+
[[package]]
name = "utf-8"
version = "0.7.6"
diff --git a/docs/actors/content/docs/migrate-queues-to-streams.mdx b/docs/actors/content/docs/migrate-queues-to-streams.mdx
new file mode 100644
index 0000000000..aa6abc63fc
--- /dev/null
+++ b/docs/actors/content/docs/migrate-queues-to-streams.mdx
@@ -0,0 +1,79 @@
+---
+title: "Migrate Queues to Streams"
+description: "Move actor queue processing to durable input streams without losing existing queued work."
+---
+
+**Queue APIs are deprecated.** Existing queues keep working, but use [actor
+streams](/actors/docs/streams) for new message processing.
+
+Streams provide:
+
+- **Writes while the consumer is down:** producers can append directly to DS while the consuming actor is asleep or unavailable. The DS service and its storage actors must still be available.
+- **More flexibility:** use one primary input per actor, plus subscriptions to other streams using path patterns and actor keys.
+- **Better observability:** inspect retained input through DS, actor progress and failures through `stream.status()`, and failed payloads in an optional dead-letter stream.
+
+The current `actor.stream.append()` helper goes through the actor gateway and
+can activate the actor. Use the direct DS HTTP endpoint when writes must be
+independent of the consuming actor's availability. Both paths accept the same
+[paired input token](/actors/docs/authentication#issue-engine-access-tokens).
+
+## Replace queue handlers
+
+Move queue processing from `run` / `c.queue.next()` into `stream.onMessage`.
+For multiple queue names, use a discriminated union in one primary input stream:
+
+
+
+Replace `account.send("credit", { amount: 25 })` with:
+
+
+
+| Queue API | Stream equivalent |
+| --- | --- |
+| `queues` and `queue()` | `stream.onMessage`, with optional `stream.schema` |
+| `handle.send(name, body)` / `connection.send(name, body)` | `handle.stream.append(body)` or direct DS append; include a type/name in the body when needed |
+| `c.queue.send(name, body)` | `c.stream.append(body)` |
+| `next`, `nextBatch`, `tryNext`, `tryNextBatch`, `iter` | `onMessage` receives one record at a time |
+| `waitForNames`, `waitForAvailable` | DS wakes the actor when input is available |
+| `complete()` | Returning successfully saves actor state and processing progress |
+| `send(..., { wait: true })`, `enqueueAndWait`, completion replies | No direct equivalent; append confirms acceptance, not processing or a result |
+| `canPublish` | Engine stream append grants plus application-specific authorization and validation |
+| Queue filters and selective receive | Dispatch by body type or use separate subscriptions; primary input is processed in stream order |
+
+Use an action for synchronous results, or model completion explicitly in actor
+state/events when processing asynchronously. Handler returns do not send a
+queue-style completion reply to the producer. `maxQueueSize` and
+`maxQueueMessageSize` do not configure streams.
+
+## Failures and retries
+
+Stream handlers get five retries with backoff after the initial attempt. Without
+DLQ, exhaustion pauses that source and calls `onError`; inspect it with
+`stream.status()` and explicitly `stream.resume()` after fixing the cause.
+With `deadLetter: true`, RivetKit durably writes a separate dead letter before
+continuing. See [failure handling and redrive](/actors/docs/streams#dead-letter-streams).
+
+Delivery is at least once. Make external side effects idempotent. Failed handler
+mutations are not rolled back, and replayed messages arrive at the stream tail.
+Stream progress does not delete acknowledged input; retention remains a separate,
+deferred concern.
+
+## Cut over without losing queued work
+
+1. Deploy the stream handler alongside the existing queue consumer.
+2. Switch producers to stream append. Stop new queue writes.
+3. Keep the old consumer running until its queue backlog and any completion replies are drained.
+4. Verify stream progress and failures, then remove the queue definitions and consumer.
+
+Queue storage is **not automatically copied** into streams. If ordering across
+the cutover matters, pause producers, drain the old queue, and then switch.
+Keep the old path available until rollback no longer requires it.
+
+Actor streams currently require the native runtime and a DS service configured
+for subscriptions. Workflow stream integration is still deferred: existing
+workflow queue APIs remain available, and a workflow migration needs its own
+completion and replay design. Do not replace a workflow's durable queue waits
+with untracked stream callbacks.
+
+Keep each primary stream for its actor's lifetime. Do not delete and recreate a
+stream to reset progress; see the [stream recommendations](/actors/docs/streams#recommendations).
diff --git a/docs/actors/content/docs/queues.mdx b/docs/actors/content/docs/queues.mdx
index 9ea27e81ee..6250067ae6 100644
--- a/docs/actors/content/docs/queues.mdx
+++ b/docs/actors/content/docs/queues.mdx
@@ -1,9 +1,15 @@
---
-title: "Queues & Run Loops"
+title: "Queues & Run Loops (Deprecated)"
description: "Process durable messages in order with Rivet Actor queues and run loops, including typed payloads, replies, and workflow integration."
skill: true
---
+**Queue APIs are deprecated.** Use [actor streams](/actors/docs/streams) for new
+message processing. Streams support direct writes while the consuming actor is
+down, flexible subscriptions, and observable delivery progress. Existing queue
+APIs continue to work. Follow the [migration guide](/actors/docs/migrate-queues-to-streams).
+Run loops remain supported; this deprecation applies to queue APIs.
+
## What are queues?
- **Realtime**: messages are delivered to a live actor as soon as possible.
diff --git a/docs/actors/content/docs/streams.mdx b/docs/actors/content/docs/streams.mdx
new file mode 100644
index 0000000000..f12c380efc
--- /dev/null
+++ b/docs/actors/content/docs/streams.mdx
@@ -0,0 +1,159 @@
+---
+title: "Streams"
+description: "Process durable input with an actor's primary stream and subscriptions to other streams."
+---
+
+Rivet Actors can process messages from [Durable Streams](/integrations/durable-streams). Each actor can have a primary input stream and subscribe to additional streams. New messages wake sleeping actors and run the matching `onMessage` handler.
+
+Actor stream handlers currently require the native runtime and a Durable Streams service configured for actor subscriptions.
+
+## Define stream handlers
+
+Use `stream.onMessage` for the actor's primary input. Use `subscriptions` for other streams that should deliver messages to this actor type. Set `DURABLE_STREAMS_ENDPOINT` to your service's stream root, such as `http://127.0.0.1:8642/durable-streams/v1/stream`.
+
+
+
+Schemas are optional. With a schema, the handler's body type is inferred from the parsed output. Without a schema, annotate the message body, as shown in the primary handler above.
+
+### Primary input stream
+
+Each actor instance has one primary input stream at `actors/{name}/{actorId}`, relative to the Durable Streams endpoint. The ID is the actor's unique ID, not its key.
+
+Append through an actor handle, using `RIVET_ENDPOINT` for your Rivet connection:
+
+
+
+`stream.append()` resolves when the message has been durably accepted, without waiting for the handler to finish. Actor code can also append to its own primary stream with `c.stream.append()`.
+
+### Subscribe to other streams
+
+In the example, `/tenants/acme/orders/alice` delivers to the `customer` actor with key `["acme", "alice"]`. The actor is created if it does not exist.
+
+- `stream` matches the complete path relative to the Durable Streams endpoint, using URLPattern pathname syntax.
+- `actorKey` maps named captures to actor key components. Literal components are also supported.
+- Subscription names select handlers. `primary` is reserved.
+- Multiple source streams can deliver to the same actor, with progress tracked separately for each source.
+
+Durable Streams registers subscriptions from deployed actor definitions, so actors do not need to wake up to register them. New named subscriptions receive writes after the configuration is adopted; they do not automatically replay earlier history. Removing a subscription stops delivery without deleting the source stream.
+
+## Processing and progress
+
+RivetKit maintains a durable read offset for each source stream. After a handler succeeds, it saves actor state and processing progress before acknowledging delivery. Sleep and restarts preserve these offsets.
+
+Stream handlers run one at a time for each actor. Messages can be redelivered after a failure, so make external side effects idempotent. There is no ordering guarantee across different streams.
+
+## Failures and resuming
+
+A failed record gets **five retries** after the initial attempt, with delays of
+1, 2, 4, 8, and 16 seconds. Failed attempts and their next retry time are durable;
+sleeping, restarting, or redeploying does not reset the retry budget. Failures to
+read DS or deliver a callback retry separately and do not consume handler attempts.
+
+Without a dead-letter stream, after the sixth failed attempt the source **pauses** and RivetKit calls that
+handler's optional `onError`. Its checkpoint stays before the failed record.
+Other actor actions and other subscribed sources remain usable.
+
+
+
+Inspect a source with `stream.status()`. After fixing the cause, call
+`stream.resume()` to retry the same record with a fresh budget. Resuming does not
+skip, delete, or reappend the record. Actor code can use the same operations on
+`c.stream`.
+
+
+
+Status and resume use `POST /gateway/{actorId}/internal/streams/status` and
+`POST /gateway/{actorId}/internal/streams/resume`. Status requires actor gateway
+read permission; resume requires actor gateway **update** permission. Default
+input tokens do not grant resume access. Raw gateway requests use `x-rivet-token`;
+the RivetKit client sets this header for you.
+
+Handler mutations are not rolled back on failure. Make side effects and
+`onError` idempotent: a crash can cause redelivery, including a repeated error
+notification. An interrupted handler can run again without counting as a
+completed failed attempt. There is no transaction spanning an external service
+and the actor's checkpoint.
+
+## Dead-letter streams
+
+Set `deadLetter: true` on a primary or named handler to send a failed record to a
+separate stream after its five retries. RivetKit calls `onError`, durably publishes
+the failed input, and then continues with the next source record.
+
+
+
+The default destination is `actors/{name}/{actorId}/dead-letters/{subscription}`;
+`subscription` is `primary` for the actor's own input. Use
+`deadLetter: { stream: "failed-orders" }` for a custom relative stream path.
+The actor host needs permission to append there. Choose a destination outside
+its input subscription patterns to avoid processing dead letters as new input.
+
+Each dead letter includes the original `body`, failure ID, actor identity,
+source path and position, attempts, and error. RivetKit persists the pending
+payload before publication and advances the source only after DS accepts it.
+If publication fails, the source stays blocked; `stream.status().deadLetter`
+reports the pending destination and error. Delivery retries without running the
+failed handler again. `stream.resume()` also retries a pending handoff, keeping
+its original destination even if configuration changes. Repair that destination
+or its permissions first. Oversized envelopes can also block publication.
+
+### Reprocess failed primary input
+
+Read a dead letter using a DS read credential, fix the cause, then pass the
+whole envelope to the owning actor's `stream.redrive()`:
+
+
+
+Redrive appends the original body to the **end of the primary input stream**.
+It leaves the dead letter intact, preserves its failure ID in a durable receipt,
+and starts a fresh handler retry budget. Supply an `idempotencyKey` and reuse it
+when retrying the same redrive request. A completed receipt prevents another
+append; reusing a key for different input fails. A new key intentionally schedules
+another replay. `nextOffset` is the DS acknowledgement boundary, not a message ID.
+
+Redrive requires actor gateway append and primary-stream append permission, so a
+paired input token works. The endpoint is
+`POST /gateway/{actorId}/internal/streams/redrive`; caller credentials are
+preserved at the DS boundary. The caller supplies the payload, so redrive is
+subject to the same trust requirements as ordinary input append.
+
+Automatic redrive is limited to primary input. For named or shared streams,
+explicitly republish to the intended destination; republishing to the original
+shared source can deliver to every subscriber. There is no dashboard redrive
+control yet.
+
+DLQ handoff and unfinished redrive requests use DS producer deduplication.
+Delivery remains at least once: the current Rivet DS producer deduplication
+window is seven days, so an uncertain append retried after that window can
+appear twice. Keep effects idempotent. Completed redrive receipts are retained
+in the actor; receipt cleanup and stream retention policies are deferred.
+
+## Authorization
+
+Issue a token on the actor handle to grant action and primary input access. Both
+RivetKit token APIs pair actor input permission with append permission for that
+actor's primary stream. Explicit action-only permissions stay action-only. See
+[Authentication](/actors/docs/authentication#issue-engine-access-tokens).
+
+The same token works with `stream.append()` and direct HTTP appends to the primary
+DS URL. Send it as `Authorization: Bearer `. DS preserves caller credentials
+for storage lookup, creation, and writes; missing or rejected credentials never
+use the service token. Append permission does not permit reading, closing, or
+deleting streams, or writing to additional subscription sources.
+
+Actor hosts need their own read permission for subscribed streams. Durable
+Streams authenticates asynchronous wakes using its service credential only after
+validating the deployed subscription and resolved actor destination.
+
+The framework endpoints are `POST /gateway/{actorId}/internal/streams/append` and
+`POST /gateway/{actorId}/internal/streams/wake`. `/internal/*` is reserved for
+RivetKit. Wakes also require a valid DS webhook signature; applications should
+use the append API instead of invoking wakes.
+
+## Recommendations
+
+**Do not delete a stream to reset an actor's input. Instead, create a new stream at a new URL and update the subscription.** For a primary input stream, keep it throughout the actor's lifetime; a replacement actor with a new actor ID has a new stream URL.
+
+Actors maintain their own read offsets. The Durable Streams specification does not define a notification that tells actors to reset those offsets when a stream is deleted and recreated. Reusing the URL can therefore cause an actor to skip new messages using its old offset. The [specification recommends against reusing deleted stream URLs](https://github.com/durable-streams/durable-streams/blob/main/PROTOCOL.md#4-stream-model).
+
+If you only want to stop accepting new messages, **close the stream instead of deleting it**. Closing preserves its data and offsets so readers can finish processing. A closed stream cannot be reopened.
diff --git a/docs/actors/sidebar.json b/docs/actors/sidebar.json
index 1fa6635812..897cd82949 100644
--- a/docs/actors/sidebar.json
+++ b/docs/actors/sidebar.json
@@ -73,10 +73,18 @@
"icon": "faTowerBroadcast"
},
{
- "title": "Queues",
+ "title": "Streams",
+ "href": "/actors/docs/streams"
+ },
+ {
+ "title": "Queues (Deprecated)",
"href": "/actors/docs/queues",
"icon": "faMailbox"
},
+ {
+ "title": "Migrate Queues to Streams",
+ "href": "/actors/docs/migrate-queues-to-streams"
+ },
{
"title": "Schedule & Cron",
"href": "/actors/docs/schedule",
diff --git a/docs/integrations/content/docs/durable-streams.mdx b/docs/integrations/content/docs/durable-streams.mdx
index a8dd3a899a..0ecfa63ad3 100644
--- a/docs/integrations/content/docs/durable-streams.mdx
+++ b/docs/integrations/content/docs/durable-streams.mdx
@@ -5,6 +5,8 @@ description: "Run the Durable Streams protocol locally and deploy it with Rivet.
[Durable Streams](https://github.com/durable-streams/durable-streams) is an open standard for real-time streams with durable, replayable history. Rivet provides an implementation backed by Rivet Actors.
+To process streams in your own actors, see [Streams](/actors/docs/streams), which covers each actor's primary input stream and subscriptions to other streams.
+
## Quickstart
@@ -119,3 +121,9 @@ Your streams are at `http://:8642/durable-streams/v1/stream/`.
+
+## Recommendations
+
+**Do not delete and recreate a stream at the same URL.** To stop accepting writes while preserving history, close the stream instead. To start fresh, create a stream at a new URL and update its consumers.
+
+Actors maintain a durable read offset for each stream. The Durable Streams specification does not define a deletion notification that resets an actor's saved offset, so reusing a deleted URL can cause it to skip new messages. See [actor stream recommendations](/actors/docs/streams#recommendations).
diff --git a/engine/artifacts/errors/stream.busy.json b/engine/artifacts/errors/stream.busy.json
new file mode 100644
index 0000000000..61c00556ca
--- /dev/null
+++ b/engine/artifacts/errors/stream.busy.json
@@ -0,0 +1,5 @@
+{
+ "code": "busy",
+ "group": "stream",
+ "message": "Actor is already processing a stream wake."
+}
\ No newline at end of file
diff --git a/engine/artifacts/errors/stream.invalid_wake.json b/engine/artifacts/errors/stream.invalid_wake.json
new file mode 100644
index 0000000000..6d66e0388b
--- /dev/null
+++ b/engine/artifacts/errors/stream.invalid_wake.json
@@ -0,0 +1,5 @@
+{
+ "code": "invalid_wake",
+ "group": "stream",
+ "message": "Invalid stream wake notification."
+}
\ No newline at end of file
diff --git a/engine/artifacts/errors/stream.not_configured.json b/engine/artifacts/errors/stream.not_configured.json
new file mode 100644
index 0000000000..36b2d59907
--- /dev/null
+++ b/engine/artifacts/errors/stream.not_configured.json
@@ -0,0 +1,5 @@
+{
+ "code": "not_configured",
+ "group": "stream",
+ "message": "Actor input stream is not configured."
+}
\ No newline at end of file
diff --git a/engine/packages/api-public/Cargo.toml b/engine/packages/api-public/Cargo.toml
index 48f473b291..5469b8f260 100644
--- a/engine/packages/api-public/Cargo.toml
+++ b/engine/packages/api-public/Cargo.toml
@@ -8,6 +8,8 @@ license.workspace = true
[dependencies]
anyhow.workspace = true
+base64.workspace = true
+ciborium.workspace = true
axum.workspace = true
epoxy-protocol.workspace = true
epoxy.workspace = true
diff --git a/engine/packages/api-public/src/actors/get_or_create.rs b/engine/packages/api-public/src/actors/get_or_create.rs
index 8cb5b52f2a..5a11e97d55 100644
--- a/engine/packages/api-public/src/actors/get_or_create.rs
+++ b/engine/packages/api-public/src/actors/get_or_create.rs
@@ -49,9 +49,10 @@ use crate::ctx::ApiCtx;
pub async fn get_or_create(
Extension(ctx): Extension,
Query(query): Query,
+ headers: axum::http::HeaderMap,
Json(body): Json,
) -> Response {
- match get_or_create_inner(ctx, query, body).await {
+ match get_or_create_inner(ctx, query, body, headers).await {
Ok(response) => Json(response).into_response(),
Err(err) => ApiError::from(err).into_response(),
}
@@ -62,14 +63,31 @@ async fn get_or_create_inner(
ctx: ApiCtx,
query: GetOrCreateQuery,
body: GetOrCreateRequest,
+ headers: axum::http::HeaderMap,
) -> Result {
- ctx.auth(
- AccessNamespaceScope::Name(query.namespace.clone()),
- ResourceKind::Actor,
- TargetScope::Any,
- OperationKind::Create,
+ if !super::stream_access::authorize(
+ &ctx,
+ &query.namespace,
+ &body.name,
+ &body.key,
+ &headers,
+ body.input.as_deref(),
)
- .await?;
+ .await?
+ {
+ ctx.auth(
+ AccessNamespaceScope::Name(query.namespace.clone()),
+ ResourceKind::Actor,
+ TargetScope::Any,
+ OperationKind::Create,
+ )
+ .await?;
+ } else {
+ anyhow::ensure!(
+ body.input.is_some(),
+ "Stream provisioning requires its authorized identity"
+ );
+ }
let namespace = ctx
.op(namespace::ops::resolve_for_name_global::Input {
diff --git a/engine/packages/api-public/src/actors/list.rs b/engine/packages/api-public/src/actors/list.rs
index 7d8caec900..e328e8a25e 100644
--- a/engine/packages/api-public/src/actors/list.rs
+++ b/engine/packages/api-public/src/actors/list.rs
@@ -42,25 +42,55 @@ use crate::{actors::utils::fetch_actors_by_ids, ctx::ApiCtx, errors};
),
security(("bearer_auth" = [])),
)]
-pub async fn list(Extension(ctx): Extension, Query(query): Query) -> Response {
- match list_inner(ctx, query).await {
+pub async fn list(
+ Extension(ctx): Extension,
+ Query(query): Query,
+ headers: axum::http::HeaderMap,
+) -> Response {
+ match list_inner(ctx, query, headers).await {
Ok(response) => Json(response).into_response(),
Err(err) => ApiError::from(err).into_response(),
}
}
-async fn list_inner(ctx: ApiCtx, query: ListQuery) -> Result {
- ctx.auth(
- AccessNamespaceScope::Name(query.namespace.clone()),
- ResourceKind::Actor,
- TargetScope::Any,
- if query.actor_ids.is_none() && query.actor_id.is_empty() && query.key.is_none() {
- OperationKind::List
- } else {
- OperationKind::Read
- },
- )
- .await?;
+async fn list_inner(
+ ctx: ApiCtx,
+ query: ListQuery,
+ headers: axum::http::HeaderMap,
+) -> Result {
+ let stream_lookup = if headers.contains_key(rivet_auth::streams::PATH_HEADER) {
+ anyhow::ensure!(
+ query.actor_ids.is_none()
+ && query.actor_id.is_empty()
+ && !query.include_destroyed.unwrap_or(false)
+ && query.cursor.is_none(),
+ "Stream lookup requires one active storage key"
+ );
+ super::stream_access::authorize(
+ &ctx,
+ &query.namespace,
+ query.name.as_deref().unwrap_or(""),
+ query.key.as_deref().unwrap_or(""),
+ &headers,
+ None,
+ )
+ .await?
+ } else {
+ false
+ };
+ if !stream_lookup {
+ ctx.auth(
+ AccessNamespaceScope::Name(query.namespace.clone()),
+ ResourceKind::Actor,
+ TargetScope::Any,
+ if query.actor_ids.is_none() && query.actor_id.is_empty() && query.key.is_none() {
+ OperationKind::List
+ } else {
+ OperationKind::Read
+ },
+ )
+ .await?;
+ }
// Parse query
let actor_ids = [
diff --git a/engine/packages/api-public/src/actors/list_names.rs b/engine/packages/api-public/src/actors/list_names.rs
index 099f641eaa..5c2eaf561e 100644
--- a/engine/packages/api-public/src/actors/list_names.rs
+++ b/engine/packages/api-public/src/actors/list_names.rs
@@ -67,16 +67,24 @@ pub(crate) async fn list_names_inner(
|ctx, query| async move {
rivet_api_peer::actors::list_names::list_names(ctx, (), query).await
},
- |_, res, agg| agg.extend(res.names),
+ |_, res, agg| {
+ for (name, actor) in res.names {
+ if let Some(existing) = agg.get_mut(&name) {
+ pegboard::actor_manifest::merge_sources(
+ &mut existing.metadata,
+ &actor.metadata,
+ );
+ } else {
+ agg.insert(name, actor);
+ }
+ }
+ },
)
- .await?
- .into_iter()
- // Apply limit
- .take(query.limit.unwrap_or(100))
- .collect::>();
+ .await?;
// Sort for consistency
all_names.sort_keys();
+ all_names.truncate(query.limit.unwrap_or(100));
let cursor = all_names.last().map(|(name, _)| name.to_string());
diff --git a/engine/packages/api-public/src/actors/mod.rs b/engine/packages/api-public/src/actors/mod.rs
index 710e3e4f96..d8959f4288 100644
--- a/engine/packages/api-public/src/actors/mod.rs
+++ b/engine/packages/api-public/src/actors/mod.rs
@@ -7,3 +7,5 @@ pub mod list_names;
pub mod reschedule;
pub mod sleep;
pub mod utils;
+
+mod stream_access;
diff --git a/engine/packages/api-public/src/actors/stream_access.rs b/engine/packages/api-public/src/actors/stream_access.rs
new file mode 100644
index 0000000000..c30d825bd0
--- /dev/null
+++ b/engine/packages/api-public/src/actors/stream_access.rs
@@ -0,0 +1,59 @@
+//! Narrow storage actor lookup/provisioning for stream-scoped credentials.
+use crate::ctx::ApiCtx;
+use anyhow::{Result, ensure};
+use axum::http::HeaderMap;
+use base64::Engine as _;
+use rivet_auth::{AccessNamespaceScope, streams};
+
+pub async fn authorize(
+ ctx: &ApiCtx,
+ namespace: &str,
+ name: &str,
+ key: &str,
+ headers: &HeaderMap,
+ input: Option<&str>,
+) -> Result {
+ if !headers.contains_key(streams::PATH_HEADER) {
+ return Ok(false);
+ }
+ ensure!(
+ name == streams::STORAGE_ACTOR_NAME,
+ "Stream credentials require a storage actor"
+ );
+ let header = |name| headers.get(name).and_then(|v| v.to_str().ok());
+ let path =
+ header(streams::PATH_HEADER).ok_or_else(|| anyhow::anyhow!("Invalid stream path"))?;
+ let tenant =
+ header(streams::TENANT_HEADER).ok_or_else(|| anyhow::anyhow!("Missing stream tenant"))?;
+ let (resource, target) = streams::storage_target(key, Some(path), Some(tenant))?;
+ let operation = streams::lookup_operation(header(streams::OPERATION_HEADER).unwrap_or(""))?;
+ ctx.auth(
+ AccessNamespaceScope::Name(namespace.into()),
+ resource,
+ target,
+ operation,
+ )
+ .await?;
+ if let Some(input) = input {
+ // Provisioning is only part of append authority; a read/delete grant
+ // cannot allocate storage actors by calling the management API directly.
+ ctx.auth(
+ AccessNamespaceScope::Name(namespace.into()),
+ resource,
+ target,
+ rivet_auth::OperationKind::Append,
+ )
+ .await?;
+ let bytes = base64::engine::general_purpose::STANDARD.decode(input)?;
+ let value: serde_json::Value = ciborium::from_reader(bytes.as_slice())?;
+ ensure!(
+ value["path"] == path && value["tenant_scope"] == tenant,
+ "Stream input does not match the authorized storage identity"
+ );
+ ensure!(
+ value.as_object().is_some_and(|v| v.len() == 2),
+ "Unexpected stream input fields"
+ );
+ }
+ Ok(true)
+}
diff --git a/engine/packages/auth-jwt/src/grants.rs b/engine/packages/auth-jwt/src/grants.rs
index 9456721edf..7173117918 100644
--- a/engine/packages/auth-jwt/src/grants.rs
+++ b/engine/packages/auth-jwt/src/grants.rs
@@ -2,13 +2,13 @@ use std::cmp::Ordering;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use rivet_auth_policy::{OperationKind, OwnedGrant, ResourceKind, Scope};
-use rivet_data::{AUTH_GRANTS_VERSION, generated::auth_grants_v1, versioned::AuthGrantsData};
+use rivet_data::{AUTH_GRANTS_VERSION, generated::auth_grants_v2, versioned::AuthGrantsData};
use vbare::OwnedVersionedData;
use crate::{Id, TokenError};
pub const MAX_GRANTS: usize = 32;
-pub const MAX_OPERATIONS_PER_GRANT: usize = 5;
+pub const MAX_OPERATIONS_PER_GRANT: usize = 7;
pub const MAX_GRANTS_BYTES: usize = 2_048;
const MAX_ENCODED_GRANTS_BYTES: usize = 2_731;
@@ -58,16 +58,33 @@ impl ValidatedGrantSet {
}
pub fn encode_grants(grants: &ValidatedGrantSet) -> Result {
- let data = auth_grants_v1::Data {
+ let version = if grants.grants.iter().any(|g| {
+ matches!(
+ g.resource,
+ ResourceKind::ActorStream | ResourceKind::DurableStream
+ ) || g
+ .operations
+ .iter()
+ .any(|o| matches!(o, OperationKind::Append | OperationKind::Close))
+ }) {
+ AUTH_GRANTS_VERSION
+ } else {
+ 1
+ };
+ encode_grants_version(grants, version)
+}
+
+fn encode_grants_version(grants: &ValidatedGrantSet, version: u16) -> Result {
+ let data = auth_grants_v2::Data {
grants: grants
.grants
.iter()
.cloned()
- .map(|grant| auth_grants_v1::Grant {
+ .map(|grant| auth_grants_v2::Grant {
resource: encode_resource(grant.resource),
target: match grant.target {
- Scope::Any => auth_grants_v1::TargetScope::Any,
- Scope::Id(id) => auth_grants_v1::TargetScope::Id(id.as_bytes()),
+ Scope::Any => auth_grants_v2::TargetScope::Any,
+ Scope::Id(id) => auth_grants_v2::TargetScope::Id(id.as_bytes()),
},
operations: grant.operations.into_iter().map(encode_operation).collect(),
})
@@ -75,7 +92,7 @@ pub fn encode_grants(grants: &ValidatedGrantSet) -> Result {
};
let encoded = AuthGrantsData::wrap_latest(data)
- .serialize_with_embedded_version(AUTH_GRANTS_VERSION)
+ .serialize_with_embedded_version(version)
.map_err(|_| TokenError::InvalidGrants)?;
if encoded.len() > MAX_GRANTS_BYTES {
return Err(TokenError::InvalidGrants);
@@ -154,8 +171,8 @@ pub fn decode_grants(
namespace: Scope::Id(namespace_id),
resource: decode_resource(grant.resource)?,
target: match grant.target {
- auth_grants_v1::TargetScope::Any => Scope::Any,
- auth_grants_v1::TargetScope::Id(id) => {
+ auth_grants_v2::TargetScope::Any => Scope::Any,
+ auth_grants_v2::TargetScope::Id(id) => {
Scope::Id(Id::from_slice(&id).map_err(|_| TokenError::InvalidGrants)?)
}
},
@@ -165,7 +182,7 @@ pub fn decode_grants(
let validated = ValidatedGrantSet::new(namespace_id, grants)?;
let version = u16::from_le_bytes([decoded[0], decoded[1]]);
- if version == AUTH_GRANTS_VERSION && encode_grants(&validated).as_deref() != Ok(encoded) {
+ if encode_grants_version(&validated, version).as_deref() != Ok(encoded) {
return Err(TokenError::InvalidGrants);
}
diff --git a/engine/packages/auth-jwt/tests/grants.rs b/engine/packages/auth-jwt/tests/grants.rs
index c4ce404d59..a93b028ec4 100644
--- a/engine/packages/auth-jwt/tests/grants.rs
+++ b/engine/packages/auth-jwt/tests/grants.rs
@@ -16,7 +16,7 @@ fn encode(namespace_id: Id, grants: &[OwnedGrant]) -> Result "actors",
ResourceKind::ActorGateway => "actor gateways",
ResourceKind::ActorKv => "actor KVs",
+ ResourceKind::ActorStream => "actor primary streams",
+ ResourceKind::DurableStream => "durable streams",
ResourceKind::Jwt => "JWTs",
ResourceKind::ApplicationToken => "application tokens",
ResourceKind::WebhookSigning => "webhook signing",
@@ -60,6 +64,8 @@ impl ResourceKind {
ResourceKind::Actor => "actor",
ResourceKind::ActorGateway => "actor-gateway",
ResourceKind::ActorKv => "actor-kv",
+ ResourceKind::ActorStream => "actor-stream",
+ ResourceKind::DurableStream => "durable-stream",
ResourceKind::Jwt => "jwt",
ResourceKind::ApplicationToken => "application-token",
ResourceKind::WebhookSigning => "webhook-signing",
@@ -94,6 +100,8 @@ pub enum OperationKind {
List,
Create,
Delete,
+ Append,
+ Close,
}
impl fmt::Display for OperationKind {
@@ -104,6 +112,8 @@ impl fmt::Display for OperationKind {
OperationKind::Update => "update",
OperationKind::Delete => "delete",
OperationKind::List => "list",
+ OperationKind::Append => "append",
+ OperationKind::Close => "close",
})
}
}
diff --git a/engine/packages/auth/src/lib.rs b/engine/packages/auth/src/lib.rs
index 0a43b9b3ea..96ea3966b0 100644
--- a/engine/packages/auth/src/lib.rs
+++ b/engine/packages/auth/src/lib.rs
@@ -2,6 +2,7 @@
mod check;
mod scope;
+pub mod streams;
pub use check::{
AuthenticatedCredential, CheckInput, CredentialKind, JwtCredential, RequestAuthState,
diff --git a/engine/packages/auth/src/streams.rs b/engine/packages/auth/src/streams.rs
new file mode 100644
index 0000000000..a0b8b45f1c
--- /dev/null
+++ b/engine/packages/auth/src/streams.rs
@@ -0,0 +1,80 @@
+//! Stream authority is bound to the storage actor's deterministic key, never to
+//! a caller-supplied resource selector alone. Primary streams use their owner's
+//! actor ID; other streams use the first 128 bits of the SHA-256 storage key.
+use crate::{OperationKind, ResourceKind, TargetScope};
+use anyhow::{Result, ensure};
+use gas::prelude::Id;
+use sha2::{Digest, Sha256};
+
+pub const STORAGE_ACTOR_NAME: &str = "durableStream";
+pub const PATH_HEADER: &str = "x-rivet-stream-path";
+pub const TENANT_HEADER: &str = "x-rivet-stream-tenant";
+pub const OPERATION_HEADER: &str = "x-rivet-stream-operation";
+
+pub fn storage_key(tenant: &str, path: &str) -> String {
+ let mut hash = Sha256::new();
+ hash.update(tenant.as_bytes());
+ hash.update([0]);
+ hash.update(path.as_bytes());
+ format!("{:x}", hash.finalize())
+}
+
+pub fn storage_target(
+ key: &str,
+ path: Option<&str>,
+ tenant: Option<&str>,
+) -> Result<(ResourceKind, TargetScope)> {
+ ensure!(
+ key.len() == 64
+ && key
+ .bytes()
+ .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
+ "Invalid stream actor key"
+ );
+ if let Some(path) = path {
+ let tenant = tenant.ok_or_else(|| anyhow::anyhow!("Missing stream tenant"))?;
+ ensure!(
+ path.starts_with('/')
+ && !path.contains(['?', '#', '\\'])
+ && storage_key(tenant, path) == key,
+ "Stream path does not match actor key"
+ );
+ if let Some((_, suffix)) = path.rsplit_once("/actors/") {
+ let parts: Vec<_> = suffix.split('/').collect();
+ if parts.len() == 2 && !parts[0].is_empty() {
+ if let Ok(id) = Id::parse(parts[1]) {
+ return Ok((ResourceKind::ActorStream, TargetScope::Id(id)));
+ }
+ }
+ }
+ } else {
+ ensure!(tenant.is_none(), "Stream tenant without path");
+ }
+ let mut bytes = [0u8; 18];
+ for (i, byte) in bytes[..16].iter_mut().enumerate() {
+ *byte = u8::from_str_radix(&key[i * 2..i * 2 + 2], 16)?;
+ }
+ Ok((ResourceKind::DurableStream, TargetScope::Id(Id::V1(bytes))))
+}
+
+/// These operations are distinct even when transported by the same HTTP method.
+pub fn data_operation(method: &str, closed: bool, fork: bool) -> Result {
+ ensure!(!fork, "Forking requires separate source authorization");
+ Ok(match (method, closed) {
+ ("GET" | "HEAD", false) => OperationKind::Read,
+ ("POST" | "PUT", true) => OperationKind::Close,
+ ("POST" | "PUT", false) => OperationKind::Append,
+ ("DELETE", false) => OperationKind::Delete,
+ _ => anyhow::bail!("Invalid stream operation"),
+ })
+}
+
+pub fn lookup_operation(value: &str) -> Result {
+ Ok(match value {
+ "append" => OperationKind::Append,
+ "read" => OperationKind::Read,
+ "close" => OperationKind::Close,
+ "delete" => OperationKind::Delete,
+ _ => anyhow::bail!("Invalid stream lookup operation"),
+ })
+}
diff --git a/engine/packages/auth/tests/streams.rs b/engine/packages/auth/tests/streams.rs
new file mode 100644
index 0000000000..1d82e2781f
--- /dev/null
+++ b/engine/packages/auth/tests/streams.rs
@@ -0,0 +1,38 @@
+use gas::prelude::Id;
+use rivet_auth::{OperationKind, ResourceKind, TargetScope, streams::*};
+
+#[test]
+fn primary_stream_authority_is_bound_to_exact_storage_key_and_owner() {
+ let owner = Id::new_v1(1);
+ let path = format!("/durable-streams/v1/stream/actors/worker/{owner}");
+ let key = storage_key("default", &path);
+ assert_eq!(
+ storage_target(&key, Some(&path), Some("default")).unwrap(),
+ (ResourceKind::ActorStream, TargetScope::Id(owner))
+ );
+ assert!(storage_target(&key, Some(&path), Some("other")).is_err());
+ assert!(storage_target(&key, Some("/other"), Some("default")).is_err());
+ let generic = storage_target(&key, None, None).unwrap();
+ assert_eq!(generic.0, ResourceKind::DurableStream);
+ assert_ne!(generic.1, TargetScope::Id(owner));
+}
+
+#[test]
+fn stream_operations_do_not_alias_read_delete_or_close() {
+ assert_eq!(
+ data_operation("POST", false, false).unwrap(),
+ OperationKind::Append
+ );
+ assert_eq!(
+ data_operation("GET", false, false).unwrap(),
+ OperationKind::Read
+ );
+ assert_eq!(
+ data_operation("DELETE", false, false).unwrap(),
+ OperationKind::Delete
+ );
+ assert_eq!(
+ data_operation("POST", true, false).unwrap(),
+ OperationKind::Close
+ );
+}
diff --git a/engine/packages/guard/src/routing/pegboard_gateway/mod.rs b/engine/packages/guard/src/routing/pegboard_gateway/mod.rs
index 11119fece4..dc607caa64 100644
--- a/engine/packages/guard/src/routing/pegboard_gateway/mod.rs
+++ b/engine/packages/guard/src/routing/pegboard_gateway/mod.rs
@@ -1,6 +1,7 @@
mod cors;
mod gateway3_selector;
mod resolve_actor_query;
+mod stream_access;
use std::{sync::Arc, time::Duration};
@@ -438,18 +439,24 @@ async fn route_request_inner(
.with_namespace_id(actor.namespace_id)
.with_actor_id(actor_id),
ctx.config().guard().route_pegboard_auth_check_timeout(),
- rivet_auth::check(
- ctx,
- shared_state.jwt_key_ring_cache.as_ref(),
- &auth_state,
- rivet_auth::CheckInput {
- token,
- namespace: AccessNamespaceScope::Id(actor.namespace_id),
- resource: ResourceKind::ActorGateway,
- target: TargetScope::Id(actor_id),
- operation: OperationKind::Read,
- },
- ),
+ async {
+ for (resource, target, operation) in requests {
+ rivet_auth::check(
+ ctx,
+ shared_state.jwt_key_ring_cache.as_ref(),
+ &auth_state,
+ rivet_auth::CheckInput {
+ token,
+ namespace: AccessNamespaceScope::Id(actor.namespace_id),
+ resource,
+ target,
+ operation,
+ },
+ )
+ .await?;
+ }
+ Ok::<(), anyhow::Error>(())
+ },
|elapsed, timeout| {
pegboard::errors::RouteAuthCheckTimeout {
actor_id: actor_id.to_string(),
diff --git a/engine/packages/guard/src/routing/pegboard_gateway/stream_access.rs b/engine/packages/guard/src/routing/pegboard_gateway/stream_access.rs
new file mode 100644
index 0000000000..ac5e33f2a9
--- /dev/null
+++ b/engine/packages/guard/src/routing/pegboard_gateway/stream_access.rs
@@ -0,0 +1,52 @@
+use anyhow::{Result, ensure};
+use base64::Engine as _;
+use hyper::{HeaderMap, Method};
+use rivet_auth::{OperationKind, ResourceKind, TargetScope, streams};
+
+fn protocol_header(headers: &HeaderMap, name: &str) -> Result> {
+ if let Some(value) = headers.get(format!("x-rivet-ds-h-{name}")) {
+ let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value.as_bytes())?;
+ return Ok(Some(String::from_utf8(bytes)?));
+ }
+ headers
+ .get(name)
+ .map(|v| v.to_str().map(str::to_owned).map_err(Into::into))
+ .transpose()
+}
+
+pub(super) fn storage_requests(
+ key: &str,
+ path: &str,
+ method: &Method,
+ headers: &HeaderMap,
+) -> Result> {
+ let header = |name| headers.get(name).and_then(|v| v.to_str().ok());
+ let tenant = header(streams::TENANT_HEADER);
+ let (resource, target) = streams::storage_target(key, header(streams::PATH_HEADER), tenant)?;
+ if path != "/protocol" {
+ return Ok(vec![(resource, target, OperationKind::Update)]);
+ }
+ let closed =
+ protocol_header(headers, "stream-closed")?.is_some_and(|v| v.eq_ignore_ascii_case("true"));
+ let parent = protocol_header(headers, "stream-forked-from")?;
+ let operation = streams::data_operation(method.as_str(), closed, false)?;
+ let mut requests = vec![(resource, target, operation)];
+ if closed {
+ requests.push((resource, target, OperationKind::Append));
+ }
+ if let Some(parent) = parent {
+ ensure!(
+ *method == Method::PUT && parent.starts_with('/'),
+ "Invalid stream fork request"
+ );
+ let tenant = tenant.ok_or_else(|| anyhow::anyhow!("Forking requires stream identity"))?;
+ let parent_key = streams::storage_key(tenant, &parent);
+ let (resource, target) = streams::storage_target(&parent_key, Some(&parent), Some(tenant))?;
+ requests.push((resource, target, OperationKind::Read));
+ }
+ Ok(requests)
+}
+
+#[cfg(test)]
+#[path = "../../../tests/unit/stream_access.rs"]
+mod tests;
diff --git a/engine/packages/guard/tests/unit/stream_access.rs b/engine/packages/guard/tests/unit/stream_access.rs
new file mode 100644
index 0000000000..88e3c6fe38
--- /dev/null
+++ b/engine/packages/guard/tests/unit/stream_access.rs
@@ -0,0 +1,50 @@
+use super::*;
+
+#[test]
+fn append_read_delete_and_bridged_close_are_distinct() {
+ let path = "/durable-streams/v1/stream/events";
+ let key = streams::storage_key("default", path);
+ let mut headers = HeaderMap::new();
+ headers.insert(streams::PATH_HEADER, path.parse().unwrap());
+ headers.insert(streams::TENANT_HEADER, "default".parse().unwrap());
+ for (method, operation) in [
+ (Method::POST, OperationKind::Append),
+ (Method::GET, OperationKind::Read),
+ (Method::DELETE, OperationKind::Delete),
+ ] {
+ assert_eq!(
+ storage_requests(&key, "/protocol", &method, &headers).unwrap()[0].2,
+ operation
+ );
+ }
+ headers.insert(
+ "x-rivet-ds-h-stream-closed",
+ base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .encode("TRUE")
+ .parse()
+ .unwrap(),
+ );
+ let requests = storage_requests(&key, "/protocol", &Method::POST, &headers).unwrap();
+ assert_eq!(requests[0].2, OperationKind::Close);
+ assert_eq!(requests[1].2, OperationKind::Append);
+ headers.insert(streams::PATH_HEADER, "/other".parse().unwrap());
+ assert!(storage_requests(&key, "/protocol", &Method::POST, &headers).is_err());
+}
+
+#[test]
+fn fork_requires_source_read_and_storage_rpc_is_not_append() {
+ let path = "/events";
+ let key = streams::storage_key("default", path);
+ let mut headers = HeaderMap::new();
+ headers.insert(streams::PATH_HEADER, path.parse().unwrap());
+ headers.insert(streams::TENANT_HEADER, "default".parse().unwrap());
+ headers.insert("stream-forked-from", "/source".parse().unwrap());
+ let requests = storage_requests(&key, "/protocol", &Method::PUT, &headers).unwrap();
+ assert_eq!(requests[0].2, OperationKind::Append);
+ assert_eq!(requests[1].2, OperationKind::Read);
+ assert_ne!(requests[0].1, requests[1].1);
+ assert_eq!(
+ storage_requests(&key, "/action/internal", &Method::POST, &headers).unwrap()[0].2,
+ OperationKind::Update
+ );
+}
diff --git a/engine/packages/pegboard-envoy/src/conn.rs b/engine/packages/pegboard-envoy/src/conn.rs
index d485b118db..d47073dbd1 100644
--- a/engine/packages/pegboard-envoy/src/conn.rs
+++ b/engine/packages/pegboard-envoy/src/conn.rs
@@ -35,6 +35,7 @@ pub struct Conn {
pub envoy_key: String,
pub envoy_conn_id: Id,
pub protocol_version: u16,
+ pub deployment_version: u32,
pub ws_handle: WebSocketHandle,
pub authorized_tunnel_routes: HashMap<(protocol::GatewayId, protocol::RequestId), ()>,
/// Tracks in-flight ToEnvoyWebSocketOpen sends so we can observe envoy-side
@@ -228,6 +229,7 @@ pub async fn init_conn(
envoy_key,
envoy_conn_id,
protocol_version,
+ deployment_version: version,
ws_handle,
authorized_tunnel_routes: HashMap::new(),
pending_websocket_opens: HashMap::new(),
diff --git a/engine/packages/pegboard-envoy/src/control_task.rs b/engine/packages/pegboard-envoy/src/control_task.rs
index 2e3e731968..5e23786333 100644
--- a/engine/packages/pegboard-envoy/src/control_task.rs
+++ b/engine/packages/pegboard-envoy/src/control_task.rs
@@ -27,6 +27,8 @@ pub(super) async fn task(
&ctx,
conn.namespace_id,
&conn.envoy_key,
+ &conn.pool_name,
+ conn.deployment_version,
metadata,
)
.await?;
diff --git a/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs b/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs
index de5b851539..1d3f384cf2 100644
--- a/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs
+++ b/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs
@@ -18,7 +18,7 @@ use gas::prelude::*;
use hyper_tungstenite::tungstenite::Message;
use pegboard::actor_kv;
use pegboard::pubsub_subjects::GatewayReceiverSubject;
-use rivet_data::converted::{ActorNameKeyData, MetadataKeyData};
+use rivet_data::converted::MetadataKeyData;
use rivet_envoy_protocol::{self as protocol, PROTOCOL_VERSION, versioned};
use rivet_guard_core::websocket_handle::WebSocketReceiver;
use rivet_metrics::GaugeGuardExt;
@@ -1359,6 +1359,8 @@ pub(super) async fn handle_metadata(
ctx: &StandaloneCtx,
namespace_id: Id,
envoy_key: &str,
+ pool_name: &str,
+ deployment_version: u32,
metadata: protocol::ToRivetMetadata,
) -> Result<()> {
let metadata = &metadata;
@@ -1367,20 +1369,28 @@ pub(super) async fn handle_metadata(
async move {
let tx = tx.with_subspace(pegboard::keys::subspace());
- // Populate actor names if provided
if let Some(actor_names) = &metadata.prepopulate_actor_names {
- // Write each actor name into the namespace actor names list
- for (name, data) in actor_names {
- let metadata = serde_json::from_str::<
- serde_json::Map,
- >(&data.metadata)
- .unwrap_or_default();
-
- tx.write(
- &pegboard::keys::ns::ActorNameKey::new(namespace_id, name.clone()),
- ActorNameKeyData { metadata },
- )?;
- }
+ let manifest = pegboard::actor_manifest::Manifest {
+ supports_stream_subscriptions: metadata
+ .metadata
+ .as_ref()
+ .and_then(|raw| serde_json::from_str::(raw).ok())
+ .is_some_and(|m| m["rivetkit"]["streamSubscriptions"] == true),
+ version: deployment_version,
+ rivetkit_version: metadata
+ .metadata
+ .as_ref()
+ .and_then(|raw| serde_json::from_str::(raw).ok())
+ .and_then(|m| m["rivetkit"]["version"].as_str().map(str::to_owned)),
+ actor_names: actor_names
+ .iter()
+ .map(|(name, data)| {
+ Ok((name.clone(), serde_json::from_str(&data.metadata)?))
+ })
+ .collect::>()?,
+ };
+ pegboard::actor_manifest::publish(&tx, namespace_id, pool_name, &manifest)
+ .await?;
}
// Write envoy metadata
diff --git a/engine/packages/pegboard/src/actor_manifest.rs b/engine/packages/pegboard/src/actor_manifest.rs
new file mode 100644
index 0000000000..e8e4d8dce2
--- /dev/null
+++ b/engine/packages/pegboard/src/actor_manifest.rs
@@ -0,0 +1,169 @@
+//! Own stream declarations by pool and deployment, rather than last host to connect.
+use anyhow::{Result, ensure};
+use futures_util::TryStreamExt;
+use gas::prelude::Id;
+use rivet_data::converted::{ActorNameKeyData, MetadataKeyData};
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value, json};
+use std::collections::{BTreeMap, BTreeSet};
+use universaldb::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Manifest {
+ #[serde(default = "manifest_supports_subscriptions")]
+ pub supports_stream_subscriptions: bool,
+ pub version: u32,
+ pub rivetkit_version: Option,
+ pub actor_names: BTreeMap>,
+}
+
+fn manifest_supports_subscriptions() -> bool {
+ true
+}
+
+/// Called in the caller's pegboard transaction, so the manifest and actor index
+/// change together. Keeping empty source records makes removals win DC merges.
+pub async fn publish(
+ tx: &universaldb::Transaction,
+ namespace: Id,
+ pool: &str,
+ manifest: &Manifest,
+) -> Result<()> {
+ if !manifest.supports_stream_subscriptions {
+ // Older hosts prepopulate names, but cannot declare removals or replace
+ // the Engine-owned index. Their payload may be incremental.
+ for (name, metadata) in &manifest.actor_names {
+ let key = crate::keys::ns::ActorNameKey::new(namespace, name.clone());
+ let existing = tx.read_opt(&key, Serializable).await?;
+ let metadata = legacy_metadata(existing.map(|data| data.metadata), metadata.clone());
+ tx.write(&key, ActorNameKeyData { metadata })?;
+ }
+ return Ok(());
+ }
+
+ let key = crate::keys::actor_manifest::MetadataKey::new(namespace, pool.to_owned());
+ let space = crate::keys::subspace().subspace(&key);
+ let chunks = tx
+ .get_ranges_keyvalues(universaldb::RangeOption::from(&space), Serializable)
+ .try_collect::>()
+ .await?;
+ let previous: Option = if chunks.is_empty() {
+ None
+ } else {
+ Some(serde_json::from_value(Value::Object(
+ key.combine(chunks)?.metadata,
+ ))?)
+ };
+ if !should_publish(previous.as_ref(), manifest)? {
+ return Ok(());
+ }
+ let names: BTreeSet<_> = manifest
+ .actor_names
+ .keys()
+ .chain(previous.iter().flat_map(|m| m.actor_names.keys()))
+ .collect();
+ for name in names {
+ let actor_key = crate::keys::ns::ActorNameKey::new(namespace, name.clone());
+ let mut data = tx
+ .read_opt(&actor_key, Serializable)
+ .await?
+ .unwrap_or(ActorNameKeyData {
+ metadata: Map::new(),
+ });
+ let mut sources = data
+ .metadata
+ .remove("streamSubscriptionSources")
+ .and_then(|v| v.as_object().cloned())
+ .unwrap_or_default();
+ let declarations = manifest
+ .actor_names
+ .get(name)
+ .and_then(|m| m.get("streamSubscriptions"))
+ .cloned()
+ .unwrap_or(json!({}));
+ sources.insert(pool.to_owned(), json!({"version": manifest.version, "rivetkitVersion": manifest.rivetkit_version, "subscriptions": declarations}));
+ if let Some(metadata) = manifest.actor_names.get(name) {
+ data.metadata.extend(metadata.clone());
+ }
+ // This field is Engine-owned, never supplied by actor hosts.
+ data.metadata
+ .insert("streamSubscriptionSources".into(), Value::Object(sources));
+ tx.write(&actor_key, data)?;
+ }
+ tx.delete_key_subspace(&key);
+ let Value::Object(metadata) = serde_json::to_value(manifest)? else {
+ unreachable!()
+ };
+ for (i, chunk) in key
+ .split(MetadataKeyData { metadata })?
+ .into_iter()
+ .enumerate()
+ {
+ tx.set(&tx.pack(&key.chunk(i)), &chunk);
+ }
+ Ok(())
+}
+
+fn legacy_metadata(
+ existing: Option>,
+ mut incoming: Map,
+) -> Map {
+ incoming.remove("streamSubscriptionSources");
+ if let Some(sources) =
+ existing.and_then(|mut metadata| metadata.remove("streamSubscriptionSources"))
+ {
+ incoming.insert("streamSubscriptionSources".into(), sources);
+ }
+ incoming
+}
+
+fn should_publish(previous: Option<&Manifest>, next: &Manifest) -> Result {
+ let Some(previous) = previous else {
+ return Ok(true);
+ };
+ if next.version < previous.version {
+ return Ok(false);
+ }
+ if next.version == previous.version {
+ ensure!(
+ next == previous,
+ "Actor metadata changed without incrementing the pool deployment version"
+ );
+ return Ok(false);
+ }
+ Ok(true)
+}
+
+/// Merge an independently replicated pool index without reviving removed rules.
+/// Conflicting declarations at the same version fail closed (empty rules).
+pub fn merge_sources(target: &mut Map, incoming: &Map) {
+ let Some(incoming) = incoming
+ .get("streamSubscriptionSources")
+ .and_then(Value::as_object)
+ else {
+ return;
+ };
+ let sources = target
+ .entry("streamSubscriptionSources")
+ .or_insert_with(|| json!({}));
+ let Some(sources) = sources.as_object_mut() else {
+ return;
+ };
+ for (pool, next) in incoming {
+ match sources.get_mut(pool) {
+ Some(old) if old["version"].as_u64() > next["version"].as_u64() => {}
+ Some(old) if old["version"] == next["version"] && old != next => {
+ old["subscriptions"] = json!({});
+ old["conflict"] = json!(true);
+ }
+ _ => {
+ sources.insert(pool.clone(), next.clone());
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+#[path = "../tests/unit/actor_manifest.rs"]
+mod tests;
diff --git a/engine/packages/pegboard/src/keys/actor_manifest.rs b/engine/packages/pegboard/src/keys/actor_manifest.rs
new file mode 100644
index 0000000000..a38b45394c
--- /dev/null
+++ b/engine/packages/pegboard/src/keys/actor_manifest.rs
@@ -0,0 +1,115 @@
+//! Full actor metadata manifest for one pool, stored with the existing versioned JSON metadata codec.
+use anyhow::{Context, Result};
+use gas::prelude::Id;
+use universaldb::prelude::*;
+use vbare::OwnedVersionedData;
+
+pub struct MetadataKey {
+ namespace_id: Id,
+ pool_name: String,
+}
+
+impl MetadataKey {
+ pub fn new(namespace_id: Id, pool_name: String) -> Self {
+ MetadataKey {
+ namespace_id,
+ pool_name,
+ }
+ }
+}
+
+impl FormalChunkedKey for MetadataKey {
+ type ChunkKey = MetadataChunkKey;
+ type Value = rivet_data::converted::MetadataKeyData;
+
+ fn chunk(&self, chunk: usize) -> Self::ChunkKey {
+ MetadataChunkKey {
+ namespace_id: self.namespace_id,
+ pool_name: self.pool_name.clone(),
+ chunk,
+ }
+ }
+
+ fn combine(&self, chunks: Vec) -> Result {
+ rivet_data::versioned::MetadataKeyData::deserialize_with_embedded_version(
+ &chunks
+ .iter()
+ .map(|x| x.value().iter().map(|x| *x))
+ .flatten()
+ .collect::>(),
+ )
+ .context("failed to combine `MetadataKey`")?
+ .try_into()
+ }
+
+ fn split(&self, value: Self::Value) -> Result>> {
+ Ok(
+ rivet_data::versioned::MetadataKeyData::wrap_latest(value.try_into()?)
+ .serialize_with_embedded_version(rivet_data::PEGBOARD_RUNNER_METADATA_VERSION)?
+ .chunks(universaldb::utils::CHUNK_SIZE)
+ .map(|x| x.to_vec())
+ .collect(),
+ )
+ }
+}
+
+impl TuplePack for MetadataKey {
+ fn pack(
+ &self,
+ w: &mut W,
+ tuple_depth: TupleDepth,
+ ) -> std::io::Result {
+ let t = (
+ NAMESPACE,
+ CONFIG,
+ DATA,
+ self.namespace_id,
+ &self.pool_name,
+ METADATA,
+ );
+ t.pack(w, tuple_depth)
+ }
+}
+
+pub struct MetadataChunkKey {
+ namespace_id: Id,
+ pool_name: String,
+ chunk: usize,
+}
+
+impl TuplePack for MetadataChunkKey {
+ fn pack(
+ &self,
+ w: &mut W,
+ tuple_depth: TupleDepth,
+ ) -> std::io::Result {
+ let t = (
+ NAMESPACE,
+ CONFIG,
+ DATA,
+ self.namespace_id,
+ &self.pool_name,
+ METADATA,
+ self.chunk,
+ );
+ t.pack(w, tuple_depth)
+ }
+}
+
+impl<'de> TupleUnpack<'de> for MetadataChunkKey {
+ fn unpack(input: &[u8], tuple_depth: TupleDepth) -> PackResult<(&[u8], Self)> {
+ let (input, (_, _, _, namespace_id, pool_name, data, chunk)) =
+ <(usize, usize, usize, Id, String, usize, usize)>::unpack(input, tuple_depth)?;
+ if data != METADATA {
+ return Err(PackError::Message("expected METADATA data".into()));
+ }
+
+ let v = MetadataChunkKey {
+ namespace_id,
+ pool_name,
+ chunk,
+ };
+
+ Ok((input, v))
+ }
+}
diff --git a/engine/packages/pegboard/src/keys/mod.rs b/engine/packages/pegboard/src/keys/mod.rs
index f9a9ef09eb..01e0e17197 100644
--- a/engine/packages/pegboard/src/keys/mod.rs
+++ b/engine/packages/pegboard/src/keys/mod.rs
@@ -10,3 +10,5 @@ pub mod runner_config;
pub fn subspace() -> universaldb::utils::Subspace {
rivet_types::keys::pegboard::subspace()
}
+
+pub mod actor_manifest;
diff --git a/engine/packages/pegboard/src/lib.rs b/engine/packages/pegboard/src/lib.rs
index 732cb7270c..50caa73bbc 100644
--- a/engine/packages/pegboard/src/lib.rs
+++ b/engine/packages/pegboard/src/lib.rs
@@ -1,6 +1,7 @@
use gas::prelude::*;
pub mod actor_kv;
+pub mod actor_manifest;
pub mod actor_sqlite;
pub mod envoy_expire_scheduler;
pub mod errors;
diff --git a/engine/packages/pegboard/src/ops/actor/get_for_gateway.rs b/engine/packages/pegboard/src/ops/actor/get_for_gateway.rs
index e3a09b052a..b52057db62 100644
--- a/engine/packages/pegboard/src/ops/actor/get_for_gateway.rs
+++ b/engine/packages/pegboard/src/ops/actor/get_for_gateway.rs
@@ -11,6 +11,7 @@ pub struct Input {
#[derive(Debug)]
pub struct Output {
pub namespace_id: Id,
+ pub name: String,
pub workflow_id: Id,
pub key: Option,
// NOTE: None if older actor has not received the new key
@@ -35,6 +36,7 @@ pub async fn pegboard_actor_get_for_gateway(
.txn("pegboard_actor_get_for_gateway", |tx| async move {
let tx = tx.with_subspace(keys::subspace());
+ let name_key = keys::actor::NameKey::new(input.actor_id);
let namespace_id_key = keys::actor::NamespaceIdKey::new(input.actor_id);
let workflow_id_key = keys::actor::WorkflowIdKey::new(input.actor_id);
let key_key = keys::actor::KeyKey::new(input.actor_id);
@@ -49,6 +51,7 @@ pub async fn pegboard_actor_get_for_gateway(
let (
namespace_id_entry,
+ name,
workflow_id_entry,
key,
runner_name_selector,
@@ -61,6 +64,7 @@ pub async fn pegboard_actor_get_for_gateway(
generation,
) = tokio::try_join!(
tx.read_opt(&namespace_id_key, Serializable),
+ tx.read_opt(&name_key, Serializable),
tx.read_opt(&workflow_id_key, Serializable),
tx.read_opt(&key_key, Serializable),
tx.read_opt(&runner_name_selector_key, Serializable),
@@ -92,6 +96,7 @@ pub async fn pegboard_actor_get_for_gateway(
Ok(Some(Output {
namespace_id,
+ name: name.unwrap_or_default(),
workflow_id,
key,
runner_name_selector,
diff --git a/engine/packages/pegboard/src/ops/runner_config/refresh_metadata.rs b/engine/packages/pegboard/src/ops/runner_config/refresh_metadata.rs
index 902eed255d..67435f71f6 100644
--- a/engine/packages/pegboard/src/ops/runner_config/refresh_metadata.rs
+++ b/engine/packages/pegboard/src/ops/runner_config/refresh_metadata.rs
@@ -1,6 +1,5 @@
use anyhow::Result;
use gas::prelude::*;
-use rivet_data::converted::ActorNameKeyData;
use rivet_types::actor::RunnerPoolError;
use std::collections::HashMap;
use universaldb::prelude::*;
@@ -83,30 +82,34 @@ pub async fn pegboard_runner_config_refresh_metadata(
.await?;
}
- // Update actor names in DB if present
- if !metadata.actor_names.is_empty() {
- ctx.udb()?
- .txn("pegboard_runner_config_write_actor_names", |tx| {
- let metadata = &metadata;
- let namespace_id = input.namespace_id;
- async move {
- let tx = tx.with_subspace(keys::subspace());
-
- for entry in &metadata.actor_names {
- tx.write(
- &keys::ns::ActorNameKey::new(namespace_id, entry.name.clone()),
- ActorNameKeyData {
- metadata: entry.metadata.clone(),
- },
- )?;
- }
-
- Ok(())
- }
- })
- .custom_instrument(tracing::info_span!("actor_name_upsert_batch_tx"))
- .await?;
- }
+ // Full manifest, including an empty set, removes declarations from old deployments.
+ ctx.udb()?
+ .txn("pegboard_runner_config_write_actor_names", |tx| {
+ let metadata = &metadata;
+ async move {
+ let tx = tx.with_subspace(keys::subspace());
+ crate::actor_manifest::publish(
+ &tx,
+ input.namespace_id,
+ &input.runner_name,
+ &crate::actor_manifest::Manifest {
+ supports_stream_subscriptions: metadata.stream_subscriptions,
+ version: metadata
+ .envoy_version
+ .or(metadata.runner_version)
+ .unwrap_or_default(),
+ rivetkit_version: Some(metadata.version.clone()),
+ actor_names: metadata
+ .actor_names
+ .iter()
+ .map(|entry| (entry.name.clone(), entry.metadata.clone()))
+ .collect(),
+ },
+ )
+ .await
+ }
+ })
+ .await?;
Ok(Ok(metadata))
}
diff --git a/engine/packages/pegboard/src/ops/serverless_metadata/fetch.rs b/engine/packages/pegboard/src/ops/serverless_metadata/fetch.rs
index b837e84ba7..aae0f172ac 100644
--- a/engine/packages/pegboard/src/ops/serverless_metadata/fetch.rs
+++ b/engine/packages/pegboard/src/ops/serverless_metadata/fetch.rs
@@ -124,6 +124,7 @@ impl From for ServerlessMetadataErrorEnvelope {
#[derive(Debug, Clone)]
pub struct Output {
+ pub stream_subscriptions: bool,
pub runtime: String,
pub version: String,
pub envoy_protocol_version: Option,
@@ -257,6 +258,7 @@ pub async fn pegboard_serverless_metadata_fetch(
};
let ServerlessMetadataPayload {
+ stream_subscriptions,
runtime,
version,
envoy_protocol_version,
@@ -308,6 +310,7 @@ pub async fn pegboard_serverless_metadata_fetch(
.collect();
Ok(Ok(Output {
+ stream_subscriptions,
runtime,
version: trimmed_version.to_owned(),
envoy_protocol_version,
diff --git a/engine/packages/pegboard/tests/unit/actor_manifest.rs b/engine/packages/pegboard/tests/unit/actor_manifest.rs
new file mode 100644
index 0000000000..7c2ad1134b
--- /dev/null
+++ b/engine/packages/pegboard/tests/unit/actor_manifest.rs
@@ -0,0 +1,70 @@
+use super::*;
+
+fn manifest(version: u32, names: &[&str]) -> Manifest {
+ Manifest {
+ supports_stream_subscriptions: true,
+ version,
+ rivetkit_version: Some("2.3.20".into()),
+ actor_names: names
+ .iter()
+ .map(|name| ((*name).into(), Map::new()))
+ .collect(),
+ }
+}
+
+#[test]
+fn deployments_are_monotonic_and_same_version_is_immutable() {
+ let old = manifest(3, &["customer"]);
+ assert!(should_publish(None, &old).unwrap());
+ assert!(!should_publish(Some(&old), &old).unwrap());
+ assert!(!should_publish(Some(&old), &manifest(2, &[])).unwrap());
+ assert!(should_publish(Some(&old), &manifest(3, &[])).is_err());
+ assert!(should_publish(Some(&old), &manifest(4, &[])).unwrap());
+}
+
+#[test]
+fn cross_datacenter_removals_win_regardless_of_response_order() {
+ let active =
+ json!({"streamSubscriptionSources":{"pool":{"version":3,"subscriptions":{"orders":{}}}}});
+ let removed = json!({"streamSubscriptionSources":{"pool":{"version":4,"subscriptions":{}}}});
+ for (first, second) in [(&active, &removed), (&removed, &active)] {
+ let mut target = first.as_object().unwrap().clone();
+ merge_sources(&mut target, second.as_object().unwrap());
+ assert_eq!(Value::Object(target), removed);
+ }
+}
+
+#[test]
+fn equal_version_conflicts_fail_closed() {
+ let mut first =
+ json!({"streamSubscriptionSources":{"pool":{"version":3,"subscriptions":{"orders":{}}}}})
+ .as_object()
+ .unwrap()
+ .clone();
+ let second =
+ json!({"streamSubscriptionSources":{"pool":{"version":3,"subscriptions":{"other":{}}}}});
+ merge_sources(&mut first, second.as_object().unwrap());
+ assert_eq!(
+ first["streamSubscriptionSources"]["pool"]["subscriptions"],
+ json!({})
+ );
+ assert_eq!(first["streamSubscriptionSources"]["pool"]["conflict"], true);
+}
+
+#[test]
+fn legacy_publishers_cannot_replace_or_invent_owned_sources() {
+ let existing =
+ json!({"streamSubscriptionSources":{"pool":{"version":4,"subscriptions":{}}},"icon":"old"})
+ .as_object()
+ .unwrap()
+ .clone();
+ let incoming=json!({"streamSubscriptionSources":{"pool":{"version":99,"subscriptions":{"bad":{}}}},"icon":"new"}).as_object().unwrap().clone();
+ let merged = legacy_metadata(Some(existing), incoming.clone());
+ assert_eq!(merged["icon"], "new");
+ assert_eq!(merged["streamSubscriptionSources"]["pool"]["version"], 4);
+ assert!(
+ legacy_metadata(None, incoming)
+ .get("streamSubscriptionSources")
+ .is_none()
+ );
+}
diff --git a/engine/sdks/rust/data/src/versioned/auth_grants.rs b/engine/sdks/rust/data/src/versioned/auth_grants.rs
index 2b84912042..6f0e6c7218 100644
--- a/engine/sdks/rust/data/src/versioned/auth_grants.rs
+++ b/engine/sdks/rust/data/src/versioned/auth_grants.rs
@@ -1,22 +1,23 @@
use anyhow::{Result, bail};
use vbare::OwnedVersionedData;
-use crate::generated::auth_grants_v1;
+use crate::generated::{auth_grants_v1, auth_grants_v2};
pub enum AuthGrantsData {
V1(auth_grants_v1::Data),
+ V2(auth_grants_v2::Data),
}
impl OwnedVersionedData for AuthGrantsData {
- type Latest = auth_grants_v1::Data;
+ type Latest = auth_grants_v2::Data;
fn wrap_latest(latest: Self::Latest) -> Self {
- Self::V1(latest)
+ Self::V2(latest)
}
fn unwrap_latest(self) -> Result {
#[allow(irrefutable_let_patterns)]
- if let Self::V1(data) = self {
+ if let Self::V2(data) = self {
Ok(data)
} else {
bail!("version not latest")
@@ -26,6 +27,7 @@ impl OwnedVersionedData for AuthGrantsData {
fn deserialize_version(payload: &[u8], version: u16) -> Result {
match version {
1 => Ok(Self::V1(serde_bare::from_slice(payload)?)),
+ 2 => Ok(Self::V2(serde_bare::from_slice(payload)?)),
_ => bail!("invalid version: {version}"),
}
}
@@ -33,6 +35,163 @@ impl OwnedVersionedData for AuthGrantsData {
fn serialize_version(self, _version: u16) -> Result> {
match self {
Self::V1(data) => serde_bare::to_vec(&data).map_err(Into::into),
+ Self::V2(data) => serde_bare::to_vec(&data).map_err(Into::into),
}
}
+ fn deserialize_converters() -> Vec Result> {
+ vec![Self::v1_to_v2]
+ }
+ fn serialize_converters() -> Vec Result> {
+ vec![Self::v2_to_v1]
+ }
+}
+impl AuthGrantsData {
+ fn v1_to_v2(self) -> Result {
+ let Self::V1(x) = self else {
+ bail!("unexpected version")
+ };
+ Ok(Self::V2(auth_grants_v2::Data {
+ grants: x
+ .grants
+ .into_iter()
+ .map(|g| {
+ Ok(auth_grants_v2::Grant {
+ resource: match g.resource {
+ auth_grants_v1::ResourceKind::Namespace => {
+ auth_grants_v2::ResourceKind::Namespace
+ }
+ auth_grants_v1::ResourceKind::Actor => {
+ auth_grants_v2::ResourceKind::Actor
+ }
+ auth_grants_v1::ResourceKind::Runner => {
+ auth_grants_v2::ResourceKind::Runner
+ }
+ auth_grants_v1::ResourceKind::RunnerConfig => {
+ auth_grants_v2::ResourceKind::RunnerConfig
+ }
+ auth_grants_v1::ResourceKind::Token => {
+ auth_grants_v2::ResourceKind::Token
+ }
+ auth_grants_v1::ResourceKind::Datacenter => {
+ auth_grants_v2::ResourceKind::Datacenter
+ }
+ auth_grants_v1::ResourceKind::ActorGateway => {
+ auth_grants_v2::ResourceKind::ActorGateway
+ }
+ auth_grants_v1::ResourceKind::ActorKv => {
+ auth_grants_v2::ResourceKind::ActorKv
+ }
+ auth_grants_v1::ResourceKind::Reserved => {
+ auth_grants_v2::ResourceKind::Reserved
+ }
+ },
+ target: match g.target {
+ auth_grants_v1::TargetScope::Any => auth_grants_v2::TargetScope::Any,
+ auth_grants_v1::TargetScope::Id(id) => {
+ auth_grants_v2::TargetScope::Id(id)
+ }
+ },
+ operations: g
+ .operations
+ .into_iter()
+ .map(|o| {
+ Ok(match o {
+ auth_grants_v1::OperationKind::Read => {
+ auth_grants_v2::OperationKind::Read
+ }
+ auth_grants_v1::OperationKind::Update => {
+ auth_grants_v2::OperationKind::Update
+ }
+ auth_grants_v1::OperationKind::List => {
+ auth_grants_v2::OperationKind::List
+ }
+ auth_grants_v1::OperationKind::Create => {
+ auth_grants_v2::OperationKind::Create
+ }
+ auth_grants_v1::OperationKind::Delete => {
+ auth_grants_v2::OperationKind::Delete
+ }
+ })
+ })
+ .collect::>>()?,
+ })
+ })
+ .collect::>>()?,
+ }))
+ }
+ fn v2_to_v1(self) -> Result {
+ let Self::V2(x) = self else {
+ bail!("unexpected version")
+ };
+ Ok(Self::V1(auth_grants_v1::Data {
+ grants: x
+ .grants
+ .into_iter()
+ .map(|g| {
+ Ok(auth_grants_v1::Grant {
+ resource: match g.resource {
+ auth_grants_v2::ResourceKind::Namespace => {
+ auth_grants_v1::ResourceKind::Namespace
+ }
+ auth_grants_v2::ResourceKind::Actor => {
+ auth_grants_v1::ResourceKind::Actor
+ }
+ auth_grants_v2::ResourceKind::Runner => {
+ auth_grants_v1::ResourceKind::Runner
+ }
+ auth_grants_v2::ResourceKind::RunnerConfig => {
+ auth_grants_v1::ResourceKind::RunnerConfig
+ }
+ auth_grants_v2::ResourceKind::Token => {
+ auth_grants_v1::ResourceKind::Token
+ }
+ auth_grants_v2::ResourceKind::Datacenter => {
+ auth_grants_v1::ResourceKind::Datacenter
+ }
+ auth_grants_v2::ResourceKind::ActorGateway => {
+ auth_grants_v1::ResourceKind::ActorGateway
+ }
+ auth_grants_v2::ResourceKind::ActorKv => {
+ auth_grants_v1::ResourceKind::ActorKv
+ }
+ auth_grants_v2::ResourceKind::Reserved => {
+ auth_grants_v1::ResourceKind::Reserved
+ }
+ _ => bail!("stream authority requires the newer schema"),
+ },
+ target: match g.target {
+ auth_grants_v2::TargetScope::Any => auth_grants_v1::TargetScope::Any,
+ auth_grants_v2::TargetScope::Id(id) => {
+ auth_grants_v1::TargetScope::Id(id)
+ }
+ },
+ operations: g
+ .operations
+ .into_iter()
+ .map(|o| {
+ Ok(match o {
+ auth_grants_v2::OperationKind::Read => {
+ auth_grants_v1::OperationKind::Read
+ }
+ auth_grants_v2::OperationKind::Update => {
+ auth_grants_v1::OperationKind::Update
+ }
+ auth_grants_v2::OperationKind::List => {
+ auth_grants_v1::OperationKind::List
+ }
+ auth_grants_v2::OperationKind::Create => {
+ auth_grants_v1::OperationKind::Create
+ }
+ auth_grants_v2::OperationKind::Delete => {
+ auth_grants_v1::OperationKind::Delete
+ }
+ _ => bail!("stream authority requires the newer schema"),
+ })
+ })
+ .collect::>>()?,
+ })
+ })
+ .collect::>>()?,
+ }))
+ }
}
diff --git a/engine/sdks/schemas/data/auth.grants.v2.bare b/engine/sdks/schemas/data/auth.grants.v2.bare
new file mode 100644
index 0000000000..10f2545e01
--- /dev/null
+++ b/engine/sdks/schemas/data/auth.grants.v2.bare
@@ -0,0 +1,42 @@
+type Any void
+
+type Id data
+
+type TargetScope union {
+ Any |
+ Id
+}
+
+type ResourceKind enum {
+ NAMESPACE
+ ACTOR
+ RUNNER
+ RUNNER_CONFIG
+ TOKEN
+ RESERVED
+ DATACENTER
+ ACTOR_GATEWAY
+ ACTOR_KV
+ ACTOR_STREAM
+ DURABLE_STREAM
+}
+
+type OperationKind enum {
+ READ
+ UPDATE
+ LIST
+ CREATE
+ DELETE
+ APPEND
+ CLOSE
+}
+
+type Grant struct {
+ resource: ResourceKind
+ target: TargetScope
+ operations: list
+}
+
+type Data struct {
+ grants: list
+}
diff --git a/examples/docs/actors-authentication/issue-token.ts b/examples/docs/actors-authentication/issue-token.ts
new file mode 100644
index 0000000000..27369e3d56
--- /dev/null
+++ b/examples/docs/actors-authentication/issue-token.ts
@@ -0,0 +1,20 @@
+import { createClient } from "rivetkit/client";
+import type { registry } from "../actors-streams/index";
+
+const client = createClient(process.env.RIVET_ENDPOINT!);
+const customer = client.customer.getOrCreate(["acme", "alice"]);
+
+// Actions plus input to this actor's primary stream, through either append path.
+const inputToken = await customer.issueToken({
+ subject: "alice",
+ expiresIn: 60,
+});
+
+// Explicit permissions replace the default: this token only grants action access.
+const actionToken = await customer.issueToken({
+ subject: "alice",
+ expiresIn: 60,
+ permissions: { actor_gateway: ["read"] },
+});
+
+export { inputToken, actionToken };
diff --git a/examples/docs/actors-streams/client.ts b/examples/docs/actors-streams/client.ts
new file mode 100644
index 0000000000..593eb9d70f
--- /dev/null
+++ b/examples/docs/actors-streams/client.ts
@@ -0,0 +1,7 @@
+import { createClient } from "rivetkit/client";
+import type { registry } from "./index";
+
+const client = createClient(process.env.RIVET_ENDPOINT!);
+const customer = client.customer.getOrCreate(["acme", "alice"]);
+
+await customer.stream.append({ amount: 5 });
diff --git a/examples/docs/actors-streams/dead-letter.ts b/examples/docs/actors-streams/dead-letter.ts
new file mode 100644
index 0000000000..763c99cb38
--- /dev/null
+++ b/examples/docs/actors-streams/dead-letter.ts
@@ -0,0 +1,15 @@
+import { actor } from "rivetkit";
+
+export const worker = actor({
+ stream: {
+ deadLetter: true,
+ async onMessage(_c, { body }: { body: { url: string } }) {
+ const response = await fetch(body.url);
+ if (!response.ok)
+ throw new Error(`Request failed: ${response.status}`);
+ },
+ onError(_c, failure) {
+ console.error("Retries exhausted", failure.stream, failure.error);
+ },
+ },
+});
diff --git a/examples/docs/actors-streams/failure.ts b/examples/docs/actors-streams/failure.ts
new file mode 100644
index 0000000000..751aeadcb6
--- /dev/null
+++ b/examples/docs/actors-streams/failure.ts
@@ -0,0 +1,19 @@
+import { actor } from "rivetkit";
+
+export const worker = actor({
+ state: { enabled: true, processed: 0 },
+ stream: {
+ onMessage(c, { body }: { body: { amount: number } }) {
+ if (!c.state.enabled) throw new Error("Processing is disabled");
+ c.state.processed += body.amount;
+ },
+ onError(_c, failure) {
+ console.error("Stream paused", failure.stream, failure.error);
+ },
+ },
+ actions: {
+ enable(c) {
+ c.state.enabled = true;
+ },
+ },
+});
diff --git a/examples/docs/actors-streams/index.ts b/examples/docs/actors-streams/index.ts
new file mode 100644
index 0000000000..3bb03cd08f
--- /dev/null
+++ b/examples/docs/actors-streams/index.ts
@@ -0,0 +1,28 @@
+import { actor, setup } from "rivetkit";
+import { z } from "zod";
+
+export const customer = actor({
+ state: { total: 0 },
+ stream: {
+ onMessage(c, { body }: { body: { amount: number } }) {
+ c.state.total += body.amount;
+ },
+ },
+ subscriptions: {
+ orders: {
+ stream: "/tenants/:tenant/orders/:customer",
+ actorKey: [":tenant", ":customer"],
+ schema: z.object({ amount: z.number() }),
+ onMessage(c, { body }) {
+ c.state.total += body.amount;
+ },
+ },
+ },
+});
+
+export const registry = setup({
+ use: { customer },
+ durableStreams: {
+ endpoint: process.env.DURABLE_STREAMS_ENDPOINT!,
+ },
+});
diff --git a/examples/docs/actors-streams/migration/client.ts b/examples/docs/actors-streams/migration/client.ts
new file mode 100644
index 0000000000..8ead107e88
--- /dev/null
+++ b/examples/docs/actors-streams/migration/client.ts
@@ -0,0 +1,9 @@
+import { createClient } from "rivetkit/client";
+import type { Registry } from "./index";
+
+const client = createClient({
+ endpoint: process.env.RIVET_ENDPOINT!,
+});
+const account = client.account.getOrCreate(["customer-123"]);
+await account.stream.append({ type: "credit", amount: 25 });
+// Append confirms durable acceptance. Processing can finish later.
diff --git a/examples/docs/actors-streams/migration/index.ts b/examples/docs/actors-streams/migration/index.ts
new file mode 100644
index 0000000000..67a90dae29
--- /dev/null
+++ b/examples/docs/actors-streams/migration/index.ts
@@ -0,0 +1,20 @@
+import { actor, setup } from "rivetkit";
+
+type Input =
+ | { type: "credit"; amount: number }
+ | { type: "debit"; amount: number };
+
+const account = actor({
+ state: { balance: 0 },
+ stream: {
+ // A schema is optional. The input type is inferred from onMessage.
+ onMessage(c, { body }: { body: Input }) {
+ if (body.type === "credit") c.state.balance += body.amount;
+ else c.state.balance -= body.amount;
+ },
+ },
+ actions: { balance: (c) => c.state.balance },
+});
+
+export const registry = setup({ use: { account } });
+export type Registry = typeof registry;
diff --git a/examples/docs/actors-streams/redrive.ts b/examples/docs/actors-streams/redrive.ts
new file mode 100644
index 0000000000..5cbedfbf85
--- /dev/null
+++ b/examples/docs/actors-streams/redrive.ts
@@ -0,0 +1,11 @@
+import type { DeadLetterMessage, StreamWriter } from "rivetkit";
+
+export async function reprocess(
+ stream: StreamWriter<{ url: string }>,
+ message: DeadLetterMessage<{ url: string }>,
+) {
+ // Reuse this key if the request times out. Use a new key for a new replay.
+ return stream.redrive(message, {
+ idempotencyKey: `repair-1:${message.id}`,
+ });
+}
diff --git a/examples/docs/actors-streams/resume.ts b/examples/docs/actors-streams/resume.ts
new file mode 100644
index 0000000000..a40b26e41d
--- /dev/null
+++ b/examples/docs/actors-streams/resume.ts
@@ -0,0 +1,19 @@
+import { createClient } from "rivetkit/client";
+import type { registry } from "./index";
+
+const client = createClient(process.env.RIVET_ENDPOINT!);
+const customer = client.customer.getOrCreate(["acme", "alice"]);
+
+const primary = await customer.stream.status();
+if (primary.failure?.paused) {
+ // Fix the cause first. Resuming retries the same blocked record.
+ await customer.stream.resume();
+}
+
+// Progress and failures are tracked separately for each subscribed source.
+const source = {
+ subscription: "orders",
+ stream: "tenants/acme/orders/alice",
+};
+const orders = await customer.stream.status(source);
+if (orders.failure?.paused) await customer.stream.resume(source);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d97cbac0a5..4ae55e2b0d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -142,10 +142,10 @@ importers:
version: 0.15.0(@bare-ts/lib@0.6.0)
'@types/node':
specifier: ^22.13.9
- version: 22.19.10
+ version: 22.19.15
tsup:
specifier: ^8.5.0
- version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@25.6.0))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0)
+ version: 8.5.1(@microsoft/api-extractor@7.53.2(@types/node@22.19.15))(@swc/core@1.15.11(@swc/helpers@0.5.17))(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0)
tsx:
specifier: ^4.20.0
version: 4.23.1
diff --git a/rivetkit-rust/engine/artifacts/errors/stream.busy.json b/rivetkit-rust/engine/artifacts/errors/stream.busy.json
new file mode 100644
index 0000000000..61c00556ca
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/stream.busy.json
@@ -0,0 +1,5 @@
+{
+ "code": "busy",
+ "group": "stream",
+ "message": "Actor is already processing a stream wake."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/stream.invalid_wake.json b/rivetkit-rust/engine/artifacts/errors/stream.invalid_wake.json
new file mode 100644
index 0000000000..6d66e0388b
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/stream.invalid_wake.json
@@ -0,0 +1,5 @@
+{
+ "code": "invalid_wake",
+ "group": "stream",
+ "message": "Invalid stream wake notification."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/stream.not_configured.json b/rivetkit-rust/engine/artifacts/errors/stream.not_configured.json
new file mode 100644
index 0000000000..36b2d59907
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/stream.not_configured.json
@@ -0,0 +1,5 @@
+{
+ "code": "not_configured",
+ "group": "stream",
+ "message": "Actor input stream is not configured."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/engine/artifacts/errors/stream.not_paused.json b/rivetkit-rust/engine/artifacts/errors/stream.not_paused.json
new file mode 100644
index 0000000000..d61b4d782f
--- /dev/null
+++ b/rivetkit-rust/engine/artifacts/errors/stream.not_paused.json
@@ -0,0 +1,5 @@
+{
+ "code": "not_paused",
+ "group": "stream",
+ "message": "Stream source is not paused."
+}
\ No newline at end of file
diff --git a/rivetkit-rust/packages/actor-persist/schemas/stream.checkpoint.v1.bare b/rivetkit-rust/packages/actor-persist/schemas/stream.checkpoint.v1.bare
new file mode 100644
index 0000000000..8f9160b0a7
--- /dev/null
+++ b/rivetkit-rust/packages/actor-persist/schemas/stream.checkpoint.v1.bare
@@ -0,0 +1,15 @@
+type Failure struct {
+ attempts: u32
+ firstFailedAt: i64
+ lastFailedAt: i64
+ nextAttemptAt: i64
+ paused: bool
+ notified: bool
+ error: str
+}
+
+type Data struct {
+ offset: str
+ completed: u64
+ failure: optional
+}
diff --git a/rivetkit-rust/packages/actor-persist/schemas/stream.checkpoint.v2.bare b/rivetkit-rust/packages/actor-persist/schemas/stream.checkpoint.v2.bare
new file mode 100644
index 0000000000..7c128201dc
--- /dev/null
+++ b/rivetkit-rust/packages/actor-persist/schemas/stream.checkpoint.v2.bare
@@ -0,0 +1,23 @@
+type Failure struct {
+ attempts: u32
+ firstFailedAt: i64
+ lastFailedAt: i64
+ nextAttemptAt: i64
+ paused: bool
+ notified: bool
+ error: str
+}
+
+type DeadLetter struct {
+ stream: str
+ producerId: str
+ envelope: data
+ deliveryError: optional
+}
+
+type Data struct {
+ offset: str
+ completed: u64
+ failure: optional
+ deadLetter: optional
+}
diff --git a/rivetkit-rust/packages/actor-persist/schemas/stream.redrive.v1.bare b/rivetkit-rust/packages/actor-persist/schemas/stream.redrive.v1.bare
new file mode 100644
index 0000000000..597b4c7733
--- /dev/null
+++ b/rivetkit-rust/packages/actor-persist/schemas/stream.redrive.v1.bare
@@ -0,0 +1,8 @@
+type Receipt struct {
+ failureId: str
+ idempotencyKey: str
+ stream: str
+ nextOffset: str
+ bodyDigest: str
+ createdAt: i64
+}
diff --git a/rivetkit-rust/packages/actor-persist/src/lib.rs b/rivetkit-rust/packages/actor-persist/src/lib.rs
index 96ecd1794e..0a23513832 100644
--- a/rivetkit-rust/packages/actor-persist/src/lib.rs
+++ b/rivetkit-rust/packages/actor-persist/src/lib.rs
@@ -75,3 +75,9 @@ impl Default for generated::v4::QueueMessage {
}
}
}
+
+pub mod stream_checkpoint;
+
+pub const STREAM_CHECKPOINT_VERSION: u16 = 2;
+
+pub mod stream_redrive;
diff --git a/rivetkit-rust/packages/actor-persist/src/stream_checkpoint.rs b/rivetkit-rust/packages/actor-persist/src/stream_checkpoint.rs
new file mode 100644
index 0000000000..fd705e8685
--- /dev/null
+++ b/rivetkit-rust/packages/actor-persist/src/stream_checkpoint.rs
@@ -0,0 +1,84 @@
+use crate::generated::{stream_checkpoint_v1 as v1, stream_checkpoint_v2 as v2};
+use anyhow::{Result, bail};
+use vbare::OwnedVersionedData;
+
+pub enum StreamCheckpoint {
+ V1(v1::Data),
+ V2(v2::Data),
+}
+impl OwnedVersionedData for StreamCheckpoint {
+ type Latest = v2::Data;
+ fn wrap_latest(data: Self::Latest) -> Self {
+ Self::V2(data)
+ }
+ fn unwrap_latest(self) -> Result {
+ match self {
+ Self::V2(data) => Ok(data),
+ _ => bail!("Version not latest"),
+ }
+ }
+ fn deserialize_version(payload: &[u8], version: u16) -> Result {
+ match version {
+ 1 => Ok(Self::V1(serde_bare::from_slice(payload)?)),
+ 2 => Ok(Self::V2(serde_bare::from_slice(payload)?)),
+ _ => bail!("Unknown stream checkpoint version"),
+ }
+ }
+ fn serialize_version(self, version: u16) -> Result> {
+ match (self, version) {
+ (Self::V1(data), 1) => Ok(serde_bare::to_vec(&data)?),
+ (Self::V2(data), 2) => Ok(serde_bare::to_vec(&data)?),
+ _ => bail!("Unknown stream checkpoint version"),
+ }
+ }
+ fn deserialize_converters() -> Vec Result> {
+ vec![Self::v1_to_v2]
+ }
+ fn serialize_converters() -> Vec Result> {
+ vec![Self::v2_to_v1]
+ }
+}
+
+impl StreamCheckpoint {
+ fn v1_to_v2(self) -> Result {
+ let Self::V1(data) = self else {
+ bail!("Unexpected version")
+ };
+ Ok(Self::V2(v2::Data {
+ offset: data.offset,
+ completed: data.completed,
+ dead_letter: None,
+ failure: data.failure.map(|f| v2::Failure {
+ attempts: f.attempts,
+ first_failed_at: f.first_failed_at,
+ last_failed_at: f.last_failed_at,
+ next_attempt_at: f.next_attempt_at,
+ paused: f.paused,
+ notified: f.notified,
+ error: f.error,
+ }),
+ }))
+ }
+ fn v2_to_v1(self) -> Result {
+ let Self::V2(data) = self else {
+ bail!("Unexpected version")
+ };
+ anyhow::ensure!(
+ data.dead_letter.is_none(),
+ "Cannot downgrade a pending dead-letter handoff"
+ );
+ Ok(Self::V1(v1::Data {
+ offset: data.offset,
+ completed: data.completed,
+ failure: data.failure.map(|f| v1::Failure {
+ attempts: f.attempts,
+ first_failed_at: f.first_failed_at,
+ last_failed_at: f.last_failed_at,
+ next_attempt_at: f.next_attempt_at,
+ paused: f.paused,
+ notified: f.notified,
+ error: f.error,
+ }),
+ }))
+ }
+}
diff --git a/rivetkit-rust/packages/actor-persist/src/stream_redrive.rs b/rivetkit-rust/packages/actor-persist/src/stream_redrive.rs
new file mode 100644
index 0000000000..af9c1d7d82
--- /dev/null
+++ b/rivetkit-rust/packages/actor-persist/src/stream_redrive.rs
@@ -0,0 +1,32 @@
+use crate::generated::stream_redrive_v1;
+use anyhow::{Result, bail};
+use vbare::OwnedVersionedData;
+pub const VERSION: u16 = 1;
+pub struct StreamRedrive(pub stream_redrive_v1::Receipt);
+impl OwnedVersionedData for StreamRedrive {
+ type Latest = stream_redrive_v1::Receipt;
+ fn wrap_latest(data: Self::Latest) -> Self {
+ Self(data)
+ }
+ fn unwrap_latest(self) -> Result {
+ Ok(self.0)
+ }
+ fn deserialize_version(payload: &[u8], version: u16) -> Result {
+ match version {
+ 1 => Ok(Self(serde_bare::from_slice(payload)?)),
+ _ => bail!("Unknown redrive receipt version"),
+ }
+ }
+ fn serialize_version(self, version: u16) -> Result> {
+ match version {
+ 1 => Ok(serde_bare::to_vec(&self.0)?),
+ _ => bail!("Unknown redrive receipt version"),
+ }
+ }
+ fn deserialize_converters() -> Vec Result> {
+ Vec:: Result>::new()
+ }
+ fn serialize_converters() -> Vec Result> {
+ Vec:: Result>::new()
+ }
+}
diff --git a/rivetkit-rust/packages/client/src/handle.rs b/rivetkit-rust/packages/client/src/handle.rs
index ff1b3701d3..752d4a1a5b 100644
--- a/rivetkit-rust/packages/client/src/handle.rs
+++ b/rivetkit-rust/packages/client/src/handle.rs
@@ -1,14 +1,14 @@
use crate::{
- common::{EncodingKind, RawWebSocket, TransportKind, HEADER_CONN_PARAMS, HEADER_ENCODING},
- connection::{start_connection, ActorConnection, ActorConnectionInner},
+ common::{EncodingKind, HEADER_CONN_PARAMS, HEADER_ENCODING, RawWebSocket, TransportKind},
+ connection::{ActorConnection, ActorConnectionInner, start_connection},
protocol::{codec, query::*},
remote_manager::{GatewayTarget, RemoteManager},
};
-use anyhow::{anyhow, Result};
+use anyhow::{Result, anyhow};
use bytes::Bytes;
use reqwest::{
- header::{HeaderMap, HeaderValue},
Method, Response,
+ header::{HeaderMap, HeaderValue},
};
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -21,13 +21,23 @@ use std::{
pub use crate::protocol::codec::{QueueSendResult, QueueSendStatus};
#[derive(Debug, Clone, Copy, Default)]
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub struct SendOpts {}
#[derive(Debug, Clone, Copy, Default)]
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub struct SendAndWaitOpts {
pub timeout: Option,
}
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+
pub type QueueSendOptions = SendAndWaitOpts;
pub struct ActorHandleStateless {
@@ -125,10 +135,18 @@ impl ActorHandleStateless {
codec::decode_http_action_response(self.encoding_kind, &output)
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
+
pub async fn send(&self, name: &str, body: impl Serialize, _opts: SendOpts) -> Result<()> {
self.send_queue(name, &body, false, None).await.map(|_| ())
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
+
pub async fn send_and_wait(
&self,
name: &str,
diff --git a/rivetkit-rust/packages/client/src/protocol/codec.rs b/rivetkit-rust/packages/client/src/protocol/codec.rs
index e60efb171d..faf31f2dc6 100644
--- a/rivetkit-rust/packages/client/src/protocol/codec.rs
+++ b/rivetkit-rust/packages/client/src/protocol/codec.rs
@@ -1,7 +1,7 @@
-use anyhow::{anyhow, Context, Result};
+use anyhow::{Context, Result, anyhow};
use rivetkit_client_protocol as wire;
use serde::Serialize;
-use serde_json::{json, Value as JsonValue};
+use serde_json::{Value as JsonValue, json};
use vbare::OwnedVersionedData;
use crate::EncodingKind;
@@ -111,6 +111,9 @@ pub fn encode_http_queue_request(
}
#[derive(Debug, Clone, PartialEq, Eq)]
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub enum QueueSendStatus {
Completed,
TimedOut,
@@ -118,6 +121,9 @@ pub enum QueueSendStatus {
}
#[derive(Debug, Clone)]
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub struct QueueSendResult {
pub status: QueueSendStatus,
pub response: Option,
diff --git a/rivetkit-rust/packages/rivetkit-core/Cargo.toml b/rivetkit-rust/packages/rivetkit-core/Cargo.toml
index 91764b56d3..76695859bb 100644
--- a/rivetkit-rust/packages/rivetkit-core/Cargo.toml
+++ b/rivetkit-rust/packages/rivetkit-core/Cargo.toml
@@ -13,6 +13,7 @@ description = "Core runtime primitives for RivetKit actor hosts"
[features]
default = ["native-runtime"]
native-runtime = [
+ "dep:ed25519-dalek",
"dep:nix",
"dep:reqwest",
"dep:opentelemetry-otlp",
@@ -36,6 +37,7 @@ anyhow.workspace = true
fs_extra = { workspace = true }
[dependencies]
+ed25519-dalek = { workspace = true, optional = true }
anyhow.workspace = true
async-trait.workspace = true
axum = { workspace = true, optional = true }
@@ -85,6 +87,7 @@ tracing.workspace = true
tracing-opentelemetry = { version = "0.29", default-features = false }
tracing-subscriber = { workspace = true, optional = true }
url.workspace = true
+urlpattern = "0.6"
vbare.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs
index 2e3bb38186..4b48b91117 100644
--- a/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs
+++ b/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs
@@ -180,8 +180,34 @@ impl ActorTracingConfig {
}
}
+/// Opt-in handoff after the handler exhausts its retry budget.
+#[derive(Clone, Debug, Default)]
+pub struct DeadLetterConfig {
+ pub stream: Option,
+}
+
+/// Trusted DS connection. Endpoint is the full stream root, without credentials.
+#[derive(Clone)]
+pub struct InputStreamConfig {
+ pub dead_letters: std::collections::BTreeMap,
+ pub primary: bool,
+ pub subscriptions:
+ std::collections::BTreeMap,
+ pub endpoint: String,
+ pub token: Option,
+}
+
+impl std::fmt::Debug for InputStreamConfig {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("InputStreamConfig")
+ .field("configured", &true)
+ .finish_non_exhaustive()
+ }
+}
+
#[derive(Clone, Debug)]
pub struct ActorConfig {
+ pub input_stream: Option,
pub name: Option,
pub icon: Option,
/// Whether the user declared a SQLite database for this actor (`db({...})`
@@ -224,6 +250,7 @@ pub struct ActorConfig {
/// Sparse, serialization-friendly actor configuration. All fields are optional with millisecond integers instead of Duration. Used at runtime boundaries (NAPI, config files). Convert to ActorConfig via ActorConfig::from_input().
#[derive(Clone, Debug, Default)]
pub struct ActorConfigInput {
+ pub input_stream: Option,
pub name: Option,
pub icon: Option,
pub has_database: Option,
@@ -257,6 +284,7 @@ pub struct ActorConfigInput {
impl ActorConfig {
pub fn from_input(config: ActorConfigInput) -> Self {
let mut actor_config = Self {
+ input_stream: config.input_stream,
name: config.name,
icon: config.icon,
has_database: config.has_database.unwrap_or(false),
@@ -350,6 +378,9 @@ impl ActorConfig {
/// etc.). Call this before constructing an `ActorFactory` from this
/// config so the actor never starts with garbage state.
pub fn validate(&self) -> anyhow::Result<()> {
+ if let Some(stream) = &self.input_stream {
+ stream.validate()?;
+ }
crate::inspector::validate_inspector_tabs(&self.inspector_tabs)?;
self.tracing.validate(&self.actions)?;
anyhow::ensure!(
@@ -377,6 +408,7 @@ impl ActorConfig {
impl Default for ActorConfig {
fn default() -> Self {
Self {
+ input_stream: None,
name: None,
icon: None,
has_database: false,
diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs
index 72d6583923..2daa5e8270 100644
--- a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs
+++ b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs
@@ -147,6 +147,10 @@ pub(crate) struct ActorContextInner {
pub(super) queue_config: Mutex,
pub(super) queue_abort_signal: Mutex,
pub(super) queue_initialize: OnceCell<()>,
+ #[cfg(feature = "native-runtime")]
+ pub(super) stream_initialize: OnceCell<()>,
+ #[cfg(feature = "native-runtime")]
+ pub(super) stream_lock: Arc>,
pub(super) queue_metadata: AsyncMutex,
pub(super) queue_receive_lock: AsyncMutex<()>,
pub(super) queue_completion_waiters: SccHashMap>>>,
@@ -490,6 +494,10 @@ impl ActorContext {
queue_config: Mutex::new(config.clone()),
queue_abort_signal: Mutex::new(abort_signal.clone()),
queue_initialize: OnceCell::new(),
+ #[cfg(feature = "native-runtime")]
+ stream_initialize: OnceCell::new(),
+ #[cfg(feature = "native-runtime")]
+ stream_lock: Arc::new(AsyncMutex::new(())),
queue_metadata: AsyncMutex::new(QueueMetadata::default()),
queue_receive_lock: AsyncMutex::new(()),
queue_completion_waiters: SccHashMap::new(),
diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/messages.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/messages.rs
index 0fd20d2afb..5f23abaa3b 100644
--- a/rivetkit-rust/packages/rivetkit-core/src/actor/messages.rs
+++ b/rivetkit-rust/packages/rivetkit-core/src/actor/messages.rs
@@ -397,6 +397,18 @@ pub struct QueueSendResult {
#[derive(Debug)]
pub enum ActorEvent {
+ StreamError {
+ subscription: String,
+ body: Vec,
+ cancel: CancellationToken,
+ reply: Reply<()>,
+ },
+ StreamMessage {
+ subscription: String,
+ body: Vec,
+ cancel: CancellationToken,
+ reply: Reply<()>,
+ },
Action {
name: String,
args: Vec,
@@ -492,6 +504,8 @@ impl ActorEvent {
pub(crate) fn kind(&self) -> &'static str {
match self {
Self::Action { .. } => "action",
+ Self::StreamMessage { .. } => "stream_message",
+ Self::StreamError { .. } => "stream_error",
Self::HttpRequest { .. } => "http_request",
Self::QueueSend { .. } => "queue_send",
Self::WebSocketOpen { .. } => "websocket_open",
diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs
index 837629ac4c..7adfb29769 100644
--- a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs
+++ b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs
@@ -25,6 +25,9 @@ pub mod sleep;
mod sql_efficiency;
pub mod sqlite;
pub mod state;
+#[cfg(feature = "native-runtime")]
+pub mod stream;
+pub mod stream_subscription;
pub mod task;
pub mod task_types;
pub(crate) mod work_registry;
diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/stream.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/stream.rs
new file mode 100644
index 0000000000..c381f07117
--- /dev/null
+++ b/rivetkit-rust/packages/rivetkit-core/src/actor/stream.rs
@@ -0,0 +1,826 @@
+//! Actor-owned JSON input stream. DS owns wake retries; core owns processing and
+//! the checkpoint. Successful handler side effects can repeat after a crash.
+use super::stream_subscription::NamedStreamSubscription;
+use rivetkit_actor_persist::{
+ STREAM_CHECKPOINT_VERSION, generated::stream_checkpoint_v2 as wire,
+ stream_checkpoint::StreamCheckpoint,
+};
+use sha2::{Digest, Sha256};
+use std::{
+ collections::{BTreeMap, HashSet},
+ time::{Duration, SystemTime, UNIX_EPOCH},
+};
+use vbare::OwnedVersionedData;
+
+use anyhow::{Context, Result, ensure};
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use ed25519_dalek::{Signature, VerifyingKey};
+use reqwest::{Client, Method};
+use rivet_error::RivetError;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use tokio::sync::oneshot;
+use tokio_util::sync::CancellationToken;
+
+use super::{
+ ActorContext, ActorEvent, RequestSaveOpts, config::InputStreamConfig, internal_storage,
+};
+
+mod dead_letter;
+
+pub const WAKE_PATH: &str = "/internal/streams/wake";
+pub const APPEND_PATH: &str = "/internal/streams/append";
+// A single fixed core-owned checkpoint in the existing indexed metadata table.
+const CHECKPOINT_KEY: &str = "input_stream_checkpoint_v1";
+const MAX_HANDLER_FAILURES: u32 = 6;
+const MAX_RESPONSE: usize = 16 * 1024 * 1024;
+
+#[derive(Debug, RivetError, Serialize)]
+#[error("stream")]
+pub enum StreamError {
+ #[error("not_configured", "Actor input stream is not configured.")]
+ NotConfigured,
+ #[error("invalid_wake", "Invalid stream wake notification.")]
+ InvalidWake,
+ #[error("busy", "Actor is already processing a stream wake.")]
+ Busy,
+ #[error("not_paused", "Stream source is not paused.")]
+ NotPaused,
+}
+
+#[derive(Serialize, Deserialize)]
+struct Checkpoint {
+ offset: String,
+ completed: usize,
+ #[serde(default)]
+ failure: Option,
+ #[serde(default)]
+ dead_letter: Option,
+}
+
+fn checkpoint_key(handler: &str, path: &str) -> Result {
+ Ok(if handler == "primary" {
+ CHECKPOINT_KEY.to_owned()
+ } else {
+ let digest = Sha256::digest(serde_json::to_vec(&(handler, path))?);
+ format!("stream_checkpoint_v1_{digest:x}")
+ })
+}
+fn now_ms() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as i64
+}
+fn retry_delay(attempts: u32) -> i64 {
+ 1_000_i64.saturating_mul(1_i64 << attempts.saturating_sub(1).min(5))
+}
+impl Checkpoint {
+ fn decode(raw: &str) -> Result {
+ // Read the original JSON checkpoint once, then migrate on the next write.
+ let Some(encoded) = raw.strip_prefix("bare:") else {
+ return Ok(serde_json::from_str(raw)?);
+ };
+ let data =
+ StreamCheckpoint::deserialize_with_embedded_version(&URL_SAFE_NO_PAD.decode(encoded)?)?;
+ Ok(Self {
+ offset: data.offset,
+ completed: usize::try_from(data.completed)?,
+ failure: data.failure,
+ dead_letter: data.dead_letter,
+ })
+ }
+ fn encode(&self) -> Result {
+ let bytes = StreamCheckpoint::wrap_latest(wire::Data {
+ offset: self.offset.clone(),
+ completed: self.completed as u64,
+ failure: self.failure.clone(),
+ dead_letter: self.dead_letter.clone(),
+ })
+ .serialize_with_embedded_version(STREAM_CHECKPOINT_VERSION)?;
+ Ok(format!("bare:{}", URL_SAFE_NO_PAD.encode(bytes)))
+ }
+ async fn save(&self, ctx: &ActorContext, key: &str) -> Result<()> {
+ internal_storage::persist_meta_text(ctx.sql(), key, &self.encode()?).await
+ }
+ fn failed(&mut self, error: &anyhow::Error, now: i64) {
+ let previous = self.failure.take();
+ let attempts = previous
+ .as_ref()
+ .map_or(1, |f| f.attempts.saturating_add(1));
+ let error = RivetError::extract(error);
+ let error = serde_json::to_string(&json!({
+ "group": error.group(),
+ "code": error.code(),
+ "message": error.message().chars().take(4096).collect::(),
+ }))
+ .expect("error object");
+ self.failure = Some(wire::Failure {
+ attempts,
+ first_failed_at: previous.map_or(now, |f| f.first_failed_at),
+ last_failed_at: now,
+ next_attempt_at: now.saturating_add(retry_delay(attempts)),
+ paused: attempts >= MAX_HANDLER_FAILURES,
+ notified: false,
+ error,
+ });
+ }
+ fn failure_info(&self, handler: &str, path: &str) -> Value {
+ let Some(f) = &self.failure else {
+ return Value::Null;
+ };
+ json!({"subscription":handler,"stream":path,"offset":self.offset,"messageIndex":self.completed,
+ "attempts":f.attempts,"firstFailedAt":f.first_failed_at,"lastFailedAt":f.last_failed_at,
+ "nextAttemptAt":if f.paused {None} else {Some(f.next_attempt_at)},"paused":f.paused,
+ "error":serde_json::from_str::(&f.error).unwrap_or(Value::Null)})
+ }
+}
+
+/// Select a single subscribed source. Primary input requires no arguments.
+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct StreamSource {
+ pub subscription: Option,
+ pub stream: Option,
+}
+
+#[derive(Deserialize)]
+struct Wake {
+ #[serde(skip)]
+ handler: String,
+ subscription_id: String,
+ wake_id: String,
+ generation: i64,
+ streams: Vec,
+ callback_url: String,
+ callback_token: String,
+}
+
+#[derive(Deserialize)]
+struct WakeStream {
+ path: String,
+ acked_offset: String,
+ tail_offset: String,
+}
+
+struct Connection {
+ dead_letters: BTreeMap,
+ primary: bool,
+ actor_key: crate::types::ActorKey,
+ subscriptions: BTreeMap,
+ client: Client,
+ root: url::Url,
+ token: Option,
+ path: String,
+ subscription: String,
+}
+
+impl Connection {
+ fn new(ctx: &ActorContext, config: InputStreamConfig) -> Result {
+ let root = url::Url::parse(&config.endpoint)?;
+ ensure!(
+ matches!(root.scheme(), "http" | "https")
+ && root.host_str().is_some()
+ && root.username().is_empty()
+ && root.password().is_none()
+ && root.query().is_none()
+ && root.fragment().is_none(),
+ "Invalid DS endpoint"
+ );
+ let mut primary_url = root.clone();
+ primary_url
+ .path_segments_mut()
+ .map_err(|_| StreamError::NotConfigured.build())?
+ .clear()
+ .push("actors")
+ .push(ctx.name())
+ .push(ctx.actor_id());
+ let primary_path = primary_url.path().trim_start_matches('/').to_owned();
+ Ok(Self {
+ primary: config.primary,
+ dead_letters: config.dead_letters,
+ actor_key: ctx.key().clone(),
+ subscriptions: config.subscriptions,
+ client: Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .timeout(Duration::from_secs(10))
+ .build()?,
+ root,
+ token: config
+ .token
+ .or_else(|| ctx.client_token().map(str::to_owned)),
+ path: primary_path,
+ subscription: format!("actor-{}", ctx.actor_id()),
+ })
+ }
+
+ fn url(&self, path: &str) -> Result {
+ let mut url = self.root.clone();
+ let mut segments = url
+ .path_segments_mut()
+ .map_err(|_| StreamError::NotConfigured.build())?;
+ segments.pop_if_empty();
+ for segment in path.split('/') {
+ segments.push(segment);
+ }
+ drop(segments);
+ Ok(url)
+ }
+
+ fn request(&self, method: Method, path: &str) -> Result {
+ let mut request = self.client.request(method, self.url(path)?);
+ if let Some(token) = &self.token {
+ request = request.bearer_auth(token);
+ }
+ Ok(request)
+ }
+
+ fn source_request(&self, method: Method, path: &str) -> Result {
+ self.source_request_with_token(method, path, self.token.as_deref())
+ }
+
+ fn source_request_with_token(
+ &self,
+ method: Method,
+ path: &str,
+ token: Option<&str>,
+ ) -> Result {
+ ensure!(
+ !path.starts_with('/') && !path.contains(['?', '#', '\\']) && path.len() <= 2048,
+ StreamError::InvalidWake.build()
+ );
+ let expected = format!("{}/{}", self.root.path().trim_end_matches('/'), path);
+ let mut url = self.root.clone();
+ url.set_path(&expected);
+ ensure!(url.path() == expected, StreamError::InvalidWake.build());
+ let mut request = self.client.request(method, url);
+ if let Some(token) = token {
+ request = request.bearer_auth(token);
+ }
+ Ok(request)
+ }
+
+ async fn callback(&self, wake: &Wake, done: bool) -> Result<()> {
+ self.callback_progress(
+ wake,
+ done,
+ if done {
+ wake.streams.iter().collect()
+ } else {
+ vec![]
+ },
+ )
+ .await
+ }
+ async fn callback_progress(
+ &self,
+ wake: &Wake,
+ done: bool,
+ streams: Vec<&WakeStream>,
+ ) -> Result<()> {
+ // Never follow an arbitrary callback URL from the request body.
+ let url = self.url(&format!(
+ "__ds/subscriptions/{}/callback",
+ wake.subscription_id
+ ))?;
+ let mut body = json!({"wake_id":wake.wake_id,"generation":wake.generation,"done":done});
+ if !streams.is_empty() {
+ body["acks"] = json!(
+ streams
+ .into_iter()
+ .map(|s| json!({"stream":s.path,"offset":s.tail_offset}))
+ .collect::>()
+ );
+ }
+ checked(
+ self.client
+ .post(url)
+ .bearer_auth(&wake.callback_token)
+ .json(&body),
+ )
+ .await?;
+ Ok(())
+ }
+
+ async fn verify(&self, signature: &str, body: &[u8]) -> Result {
+ ensure!(body.len() <= 64 * 1024, StreamError::InvalidWake.build());
+ let fields = signature
+ .split(',')
+ .map(|field| field.trim().split_once('='))
+ .collect::>>()
+ .ok_or_else(|| StreamError::InvalidWake.build())?;
+ ensure!(fields.len() == 3, StreamError::InvalidWake.build());
+ let field = |name| -> Result<&str> {
+ let values = fields
+ .iter()
+ .filter(|(key, _)| *key == name)
+ .collect::>();
+ ensure!(values.len() == 1, StreamError::InvalidWake.build());
+ Ok(values[0].1)
+ };
+ let timestamp = field("t")?;
+ let ts: u64 = timestamp
+ .parse()
+ .map_err(|_| StreamError::InvalidWake.build())?;
+ let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
+ ensure!(now.abs_diff(ts) <= 300, StreamError::InvalidWake.build());
+ let kid = field("kid")?;
+ let signature = Signature::from_slice(
+ &URL_SAFE_NO_PAD
+ .decode(field("ed25519")?)
+ .map_err(|_| StreamError::InvalidWake.build())?,
+ )
+ .map_err(|_| StreamError::InvalidWake.build())?;
+ let (_, bytes) = checked(self.request(Method::GET, "__ds/jwks.json")?).await?;
+ let jwks: Value = serde_json::from_slice(&bytes)?;
+ let key = jwks["keys"]
+ .as_array()
+ .and_then(|keys| {
+ keys.iter()
+ .find(|key| key["kid"] == kid && key["kty"] == "OKP" && key["crv"] == "Ed25519")
+ })
+ .ok_or_else(|| StreamError::InvalidWake.build())?;
+ let public: [u8; 32] = URL_SAFE_NO_PAD
+ .decode(
+ key["x"]
+ .as_str()
+ .ok_or_else(|| StreamError::InvalidWake.build())?,
+ )?
+ .try_into()
+ .map_err(|_| StreamError::InvalidWake.build())?;
+ let mut signed = format!("{timestamp}.").into_bytes();
+ signed.extend_from_slice(body);
+ VerifyingKey::from_bytes(&public)?
+ .verify_strict(&signed, &signature)
+ .map_err(|_| StreamError::InvalidWake.build())?;
+ let mut wake: Wake =
+ serde_json::from_slice(body).map_err(|_| StreamError::InvalidWake.build())?;
+
+ wake.handler = self.validate_destination(&wake)?;
+ ensure!(
+ !wake.wake_id.is_empty()
+ && wake.generation >= 0
+ && wake.callback_url
+ == self
+ .url(&format!(
+ "__ds/subscriptions/{}/callback",
+ wake.subscription_id
+ ))?
+ .as_str(),
+ StreamError::InvalidWake.build()
+ );
+ Ok(wake)
+ }
+ fn validate_destination(&self, wake: &Wake) -> Result {
+ ensure!(
+ !wake.streams.is_empty() && wake.streams.len() <= 4096,
+ StreamError::InvalidWake.build()
+ );
+ let mut seen = HashSet::new();
+ ensure!(
+ wake.streams.iter().all(|s| seen.insert(&s.path)),
+ StreamError::InvalidWake.build()
+ );
+ if wake.subscription_id == self.subscription {
+ ensure!(
+ self.primary && wake.streams.len() == 1 && wake.streams[0].path == self.path,
+ StreamError::InvalidWake.build()
+ );
+ return Ok("primary".into());
+ }
+ let name = wake
+ .subscription_id
+ .strip_prefix(&format!("{}-", self.subscription))
+ .ok_or_else(|| StreamError::InvalidWake.build())?;
+ let route = self
+ .subscriptions
+ .get(name)
+ .ok_or_else(|| StreamError::InvalidWake.build())?;
+ for stream in &wake.streams {
+ let resolved = route
+ .resolve_key(&stream.path)?
+ .ok_or_else(|| StreamError::InvalidWake.build())?;
+ let expected = resolved
+ .into_iter()
+ .map(crate::types::ActorKeySegment::String)
+ .collect::>();
+ ensure!(expected == self.actor_key, StreamError::InvalidWake.build());
+ }
+ Ok(name.to_owned())
+ }
+}
+
+#[cfg(test)]
+#[path = "../../tests/unit/stream.rs"]
+mod tests;
+
+async fn checked(
+ request: reqwest::RequestBuilder,
+) -> Result<(reqwest::header::HeaderMap, Vec)> {
+ let mut response = request.send().await.map_err(reqwest::Error::without_url)?;
+ if !response.status().is_success() {
+ tracing::warn!(status = %response.status(), "Durable Streams request rejected");
+ }
+ ensure!(
+ response.status().is_success(),
+ "DS HTTP {}",
+ response.status()
+ );
+ let headers = response.headers().clone();
+ let mut body = Vec::new();
+ while let Some(chunk) = response
+ .chunk()
+ .await
+ .map_err(reqwest::Error::without_url)?
+ {
+ ensure!(
+ body.len() + chunk.len() <= MAX_RESPONSE,
+ "DS response exceeds input limit"
+ );
+ body.extend_from_slice(&chunk);
+ }
+ Ok((headers, body))
+}
+
+impl ActorContext {
+ pub async fn stream_append_cbor(&self, body: &[u8]) -> Result<()> {
+ self.stream_append(ciborium::from_reader(body)?).await
+ }
+ fn stream_connection(&self) -> Result {
+ let config = self
+ .0
+ .queue_config
+ .lock()
+ .input_stream
+ .clone()
+ .ok_or_else(|| StreamError::NotConfigured.build())?;
+ Connection::new(self, config)
+ }
+
+ /// Durable acceptance, not processing completion. A JSON value is one record;
+ /// the singleton outer array preserves array-valued messages.
+ pub async fn stream_append(&self, body: Value) -> Result<()> {
+ let token = self.stream_connection()?.token.clone();
+ self.stream_append_authenticated(body, token.as_deref())
+ .await
+ }
+
+ /// Public append must retain the caller's authority through the DS boundary.
+ pub async fn stream_append_authenticated(
+ &self,
+ body: Value,
+ token: Option<&str>,
+ ) -> Result<()> {
+ let token = token
+ .filter(|t| !t.is_empty())
+ .ok_or_else(|| StreamError::InvalidWake.build())?;
+ let _awake = self.keep_awake_region();
+ let connection = self.stream_connection()?;
+ ensure!(connection.primary, StreamError::NotConfigured.build());
+ self.0
+ .stream_initialize
+ .get_or_try_init(|| async {
+ checked(
+ connection
+ .source_request_with_token(Method::PUT, &connection.path, Some(token))?
+ .header("content-type", "application/json"),
+ )
+ .await?;
+
+ Ok::<_, anyhow::Error>(())
+ })
+ .await?;
+ checked(
+ connection
+ .source_request_with_token(Method::POST, &connection.path, Some(token))?
+ .json(&json!([body])),
+ )
+ .await?;
+ Ok(())
+ }
+
+ /// Verify before accepting work. DS will retry a rejected/busy notification.
+ pub async fn stream_wake(&self, signature: &str, body: &[u8]) -> Result<()> {
+ let awake = self.keep_awake_region();
+ let connection = self.stream_connection()?;
+ let wake = connection.verify(signature, body).await?;
+ let lock = self
+ .0
+ .stream_lock
+ .clone()
+ .try_lock_owned()
+ .map_err(|_| StreamError::Busy.build())?;
+ let ctx = self.clone();
+ self.register_task(async move {
+ let _awake = awake;
+ let _lock = lock;
+ let cancel = CancellationToken::new();
+ let shutdown = ctx.shutdown_deadline_token();
+ let result = tokio::select! {
+ result = ctx.drain_stream(&connection, &wake, &cancel) => result,
+ result = async {
+ loop {
+ tokio::time::sleep(Duration::from_secs(10)).await;
+ connection.callback(&wake, false).await?;
+ }
+ #[allow(unreachable_code)] Ok::<(), anyhow::Error>(())
+ } => result,
+ _ = shutdown.cancelled() => Err(anyhow::anyhow!("Stream processing canceled during shutdown")),
+ };
+ cancel.cancel();
+ if let Err(error) = result {
+ tracing::error!(
+ actor_id = ctx.actor_id(),
+ ?error,
+ "actor input stream processing failed; delivery remains unacknowledged"
+ );
+ }
+ });
+ Ok(())
+ }
+
+ async fn drain_stream(
+ &self,
+ connection: &Connection,
+ wake: &Wake,
+ cancel: &CancellationToken,
+ ) -> Result<()> {
+ let mut completed = Vec::new();
+ for stream in &wake.streams {
+ if self
+ .drain_source(connection, &wake.handler, stream, cancel)
+ .await?
+ {
+ completed.push(stream);
+ }
+ }
+ connection
+ .callback_progress(wake, completed.len() == wake.streams.len(), completed)
+ .await
+ }
+
+ fn stream_source(
+ &self,
+ connection: &Connection,
+ source: StreamSource,
+ ) -> Result<(String, String)> {
+ let handler = source.subscription.unwrap_or_else(|| "primary".into());
+ let path = if handler == "primary" {
+ ensure!(
+ connection.primary && source.stream.as_ref().is_none_or(|p| p == &connection.path),
+ StreamError::NotConfigured.build()
+ );
+ connection.path.clone()
+ } else {
+ let path = source
+ .stream
+ .context("Named subscriptions require a stream path")?;
+ let route = connection
+ .subscriptions
+ .get(&handler)
+ .ok_or_else(|| StreamError::NotConfigured.build())?;
+ let key = route
+ .resolve_key(&path)?
+ .ok_or_else(|| StreamError::NotConfigured.build())?;
+ ensure!(
+ key.into_iter()
+ .map(crate::types::ActorKeySegment::String)
+ .collect::>()
+ == connection.actor_key,
+ StreamError::NotConfigured.build()
+ );
+ // Validate path canonicalization using the same read boundary.
+ let _ = connection.source_request(Method::HEAD, &path)?;
+ path
+ };
+ Ok((handler, path))
+ }
+
+ pub async fn stream_status_cbor(&self, source: &[u8]) -> Result> {
+ let value = self.stream_status(ciborium::from_reader(source)?).await?;
+ let mut bytes = Vec::new();
+ ciborium::into_writer(&value, &mut bytes)?;
+ Ok(bytes)
+ }
+ pub async fn stream_resume_cbor(&self, source: &[u8]) -> Result<()> {
+ self.stream_resume(ciborium::from_reader(source)?).await
+ }
+ pub async fn stream_status(&self, source: StreamSource) -> Result {
+ let connection = self.stream_connection()?;
+ let (handler, path) = self.stream_source(&connection, source)?;
+ let checkpoint =
+ internal_storage::load_meta_text(self.sql(), &checkpoint_key(&handler, &path)?)
+ .await?
+ .map(|s| Checkpoint::decode(&s))
+ .transpose()?;
+ Ok(
+ json!({"subscription":handler,"stream":path,"offset":checkpoint.as_ref().map(|c|&c.offset),
+ "completed":checkpoint.as_ref().map_or(0, |c|c.completed),
+ "deadLetter":checkpoint.as_ref().and_then(|c|c.dead_letter.as_ref()).map(|d|json!({"stream":d.stream,"pending":true,"error":d.delivery_error})),
+ "deadLetterStream":connection.dead_letter_path(&handler),
+ "failure":checkpoint.as_ref().map_or(Value::Null,|c|c.failure_info(&handler,&path))}),
+ )
+ }
+
+ pub async fn stream_resume(&self, source: StreamSource) -> Result<()> {
+ let awake = self.keep_awake_region();
+ let lock = self
+ .0
+ .stream_lock
+ .clone()
+ .try_lock_owned()
+ .map_err(|_| StreamError::Busy.build())?;
+ let connection = self.stream_connection()?;
+ let (handler, path) = self.stream_source(&connection, source)?;
+ let key = checkpoint_key(&handler, &path)?;
+ let mut checkpoint = internal_storage::load_meta_text(self.sql(), &key)
+ .await?
+ .map(|s| Checkpoint::decode(&s))
+ .transpose()?
+ .ok_or_else(|| StreamError::NotPaused.build())?;
+ ensure!(
+ checkpoint.failure.as_ref().is_some_and(|f| f.paused),
+ StreamError::NotPaused.build()
+ );
+ if checkpoint.dead_letter.is_none() {
+ checkpoint.failure = None;
+ }
+ checkpoint.save(self, &key).await?;
+ let ctx = self.clone();
+ self.register_task(async move {
+ let _awake = awake;
+ let _lock = lock;
+ let cancel = CancellationToken::new();
+ let result = async {
+ let (headers, _) = checked(connection.source_request(Method::HEAD, &path)?).await?;
+ let tail = headers.get("stream-next-offset").context("DS omitted Stream-Next-Offset")?.to_str()?.to_owned();
+ ctx.drain_source(&connection, &handler, &WakeStream {path, acked_offset:checkpoint.offset,tail_offset:tail}, &cancel).await
+ };
+ let shutdown = ctx.shutdown_deadline_token();
+ tokio::select! {
+ result = result => if let Err(error) = result { tracing::warn!(?error, "Resumed stream awaits redelivery"); },
+ _ = shutdown.cancelled() => {},
+ }
+ cancel.cancel();
+ });
+ Ok(())
+ }
+
+ async fn report_stream_failure(
+ &self,
+ checkpoint: &mut Checkpoint,
+ key: &str,
+ handler: &str,
+ path: &str,
+ cancel: &CancellationToken,
+ ) -> Result<()> {
+ if checkpoint.failure.as_ref().is_some_and(|f| !f.notified) {
+ let mut body = Vec::new();
+ ciborium::into_writer(&checkpoint.failure_info(handler, path), &mut body)?;
+ let (tx, rx) = oneshot::channel();
+ self.try_send_actor_event(
+ ActorEvent::StreamError {
+ subscription: handler.into(),
+ body,
+ cancel: cancel.clone(),
+ reply: tx.into(),
+ },
+ "stream_error",
+ )?;
+ if let Err(error) = rx.await.context("Stream error handler stopped")? {
+ tracing::warn!(?error, "stream.onError failed; source remains paused");
+ }
+ checkpoint
+ .failure
+ .as_mut()
+ .expect("failure exists")
+ .notified = true;
+ checkpoint.save(self, key).await?;
+ }
+ Ok(())
+ }
+
+ async fn drain_source(
+ &self,
+ connection: &Connection,
+ handler: &str,
+ stream: &WakeStream,
+ cancel: &CancellationToken,
+ ) -> Result {
+ let key = checkpoint_key(handler, &stream.path)?;
+ let mut checkpoint = match internal_storage::load_meta_text(self.sql(), &key).await? {
+ Some(raw) => Checkpoint::decode(&raw)?,
+ None => Checkpoint {
+ offset: stream.acked_offset.clone(),
+ completed: 0,
+ failure: None,
+ dead_letter: None,
+ },
+ };
+ if checkpoint.offset < stream.acked_offset && checkpoint.dead_letter.is_none() {
+ checkpoint = Checkpoint {
+ offset: stream.acked_offset.clone(),
+ completed: 0,
+ failure: None,
+ dead_letter: None,
+ };
+ }
+ // Complete a persisted handoff before reading the source again.
+ if checkpoint.dead_letter.is_some() {
+ if !self
+ .deliver_dead_letter(connection, &mut checkpoint, &key)
+ .await?
+ {
+ return Ok(false);
+ }
+ self.finish_stream_record(&mut checkpoint, &key).await?;
+ }
+ while checkpoint.offset < stream.tail_offset {
+ if checkpoint.failure.as_ref().is_some_and(|f| f.paused)
+ && connection.dead_letter_path(handler).is_none()
+ {
+ self.report_stream_failure(&mut checkpoint, &key, handler, &stream.path, cancel)
+ .await?;
+ return Ok(false);
+ }
+ let (headers, body) = checked(
+ connection
+ .source_request(Method::GET, &stream.path)?
+ .query(&[("offset", &checkpoint.offset)]),
+ )
+ .await?;
+ let next = headers
+ .get("stream-next-offset")
+ .context("DS omitted Stream-Next-Offset")?
+ .to_str()?
+ .to_owned();
+ let messages: Vec = serde_json::from_slice(&body)?;
+ ensure!(
+ next > checkpoint.offset && !messages.is_empty(),
+ "Stream no longer contains pending input"
+ );
+ for message in &messages[checkpoint.completed.min(messages.len())..] {
+ loop {
+ if checkpoint.failure.as_ref().is_some_and(|f| f.paused) {
+ self.report_stream_failure(
+ &mut checkpoint,
+ &key,
+ handler,
+ &stream.path,
+ cancel,
+ )
+ .await?;
+ if let Some(target) = connection.dead_letter_path(handler) {
+ self.prepare_dead_letter(
+ &mut checkpoint,
+ &key,
+ handler,
+ &stream.path,
+ target,
+ message,
+ )
+ .await?;
+ if self
+ .deliver_dead_letter(connection, &mut checkpoint, &key)
+ .await?
+ {
+ break;
+ }
+ }
+ return Ok(false);
+ }
+ if let Some(failure) = &checkpoint.failure {
+ tokio::time::sleep(Duration::from_millis(
+ failure.next_attempt_at.saturating_sub(now_ms()).max(0) as u64,
+ ))
+ .await;
+ }
+ let mut body = Vec::new();
+ ciborium::into_writer(message, &mut body)?;
+ let (tx, rx) = oneshot::channel();
+ self.try_send_actor_event(
+ ActorEvent::StreamMessage {
+ subscription: handler.into(),
+ body,
+ cancel: cancel.clone(),
+ reply: tx.into(),
+ },
+ "stream_message",
+ )?;
+ match rx.await.context("Stream handler stopped")? {
+ Ok(()) => break,
+ Err(error) => {
+ checkpoint.failed(&error, now_ms());
+ checkpoint.save(self, &key).await?;
+ }
+ }
+ }
+ self.finish_stream_record(&mut checkpoint, &key).await?;
+ }
+ checkpoint.offset = next;
+ checkpoint.completed = checkpoint.completed.saturating_sub(messages.len());
+ checkpoint.save(self, &key).await?;
+ }
+ Ok(true)
+ }
+}
diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/stream/dead_letter.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/stream/dead_letter.rs
new file mode 100644
index 0000000000..8236b27f6c
--- /dev/null
+++ b/rivetkit-rust/packages/rivetkit-core/src/actor/stream/dead_letter.rs
@@ -0,0 +1,223 @@
+//! Durable handoff and explicit, idempotent primary-input redrive.
+use super::*;
+use rivetkit_actor_persist::{
+ generated::stream_redrive_v1::Receipt,
+ stream_redrive::{StreamRedrive, VERSION},
+};
+
+impl Connection {
+ pub(super) fn dead_letter_path(&self, handler: &str) -> Option {
+ self.dead_letters.get(handler).map(|config| {
+ config
+ .stream
+ .clone()
+ .unwrap_or_else(|| format!("{}/dead-letters/{handler}", self.path))
+ })
+ }
+ async fn append_once(
+ &self,
+ path: &str,
+ producer: &str,
+ body: &Value,
+ token: Option<&str>,
+ ) -> Result {
+ checked(
+ self.source_request_with_token(Method::PUT, path, token)?
+ .header("content-type", "application/json"),
+ )
+ .await?;
+ let (headers, _) = checked(
+ self.source_request_with_token(Method::POST, path, token)?
+ .header("Producer-Id", producer)
+ .header("Producer-Epoch", "0")
+ .header("Producer-Seq", "0")
+ .json(&json!([body])),
+ )
+ .await?;
+ Ok(headers
+ .get("stream-next-offset")
+ .context("DS omitted Stream-Next-Offset")?
+ .to_str()?
+ .to_owned())
+ }
+}
+
+impl ActorContext {
+ pub(super) async fn prepare_dead_letter(
+ &self,
+ checkpoint: &mut Checkpoint,
+ key: &str,
+ handler: &str,
+ path: &str,
+ target: String,
+ message: &Value,
+ ) -> Result<()> {
+ ensure!(
+ target != path,
+ "Dead-letter destination cannot be its source"
+ );
+ let id = format!(
+ "{:x}",
+ Sha256::digest(serde_json::to_vec(&(
+ self.actor_id(),
+ handler,
+ path,
+ &checkpoint.offset,
+ checkpoint.completed
+ ))?)
+ );
+ let envelope = json!({"id":id,"actor":{"id":self.actor_id(),"name":self.name(),"key":self.key()},
+ "subscription":handler,"source":{"stream":path,"offset":checkpoint.offset,"messageIndex":checkpoint.completed},
+ "body":message,"failure":checkpoint.failure_info(handler,path)});
+ checkpoint.dead_letter = Some(wire::DeadLetter {
+ stream: target,
+ producer_id: format!("rivet-dlq-{id}"),
+ envelope: serde_json::to_vec(&envelope)?,
+ delivery_error: None,
+ });
+ checkpoint.save(self, key).await
+ }
+ pub(super) async fn deliver_dead_letter(
+ &self,
+ connection: &Connection,
+ checkpoint: &mut Checkpoint,
+ key: &str,
+ ) -> Result {
+ let pending = checkpoint
+ .dead_letter
+ .as_mut()
+ .context("Missing pending handoff")?;
+ let result = connection
+ .append_once(
+ &pending.stream,
+ &pending.producer_id,
+ &serde_json::from_slice(&pending.envelope)?,
+ connection.token.as_deref(),
+ )
+ .await;
+ if let Err(error) = result {
+ pending.delivery_error = Some(format!("{error:#}").chars().take(4096).collect());
+ checkpoint.save(self, key).await?;
+ return Ok(false);
+ }
+ Ok(true)
+ }
+ pub(super) async fn finish_stream_record(
+ &self,
+ checkpoint: &mut Checkpoint,
+ key: &str,
+ ) -> Result<()> {
+ self.request_save_and_wait(RequestSaveOpts {
+ immediate: true,
+ ..Default::default()
+ })
+ .await?;
+ checkpoint.failure = None;
+ checkpoint.dead_letter = None;
+ checkpoint.completed += 1;
+ checkpoint.save(self, key).await
+ }
+ pub async fn stream_redrive_cbor(&self, request: &[u8]) -> Result> {
+ let request: Value = ciborium::from_reader(request)?;
+ let token = self.stream_connection()?.token;
+ let value = self
+ .stream_redrive_authenticated(request, token.as_deref())
+ .await?;
+ let mut bytes = Vec::new();
+ ciborium::into_writer(&value, &mut bytes)?;
+ Ok(bytes)
+ }
+ /// Appends original input at the primary stream tail. Requires an explicit retry identity.
+ pub async fn stream_redrive_authenticated(
+ &self,
+ request: Value,
+ token: Option<&str>,
+ ) -> Result {
+ let token = token
+ .filter(|t| !t.is_empty())
+ .context("Redrive requires caller credentials")?;
+ let _awake = self.keep_awake_region();
+ // Serialize receipt checks with other redrives and stream processing.
+ let _lock = self
+ .0
+ .stream_lock
+ .clone()
+ .try_lock_owned()
+ .map_err(|_| StreamError::Busy.build())?;
+ let connection = self.stream_connection()?;
+ ensure!(connection.primary, StreamError::NotConfigured.build());
+ let letter = request
+ .get("message")
+ .context("Missing dead-letter message")?;
+ ensure!(
+ letter["actor"]["id"].as_str() == Some(self.actor_id())
+ && letter["actor"]["name"].as_str() == Some(self.name()),
+ "Dead letter belongs to another actor"
+ );
+ ensure!(
+ letter["subscription"] == "primary" && letter["source"]["stream"] == connection.path,
+ "Only primary-input redrive is supported; shared sources require explicit republishing"
+ );
+ let failure_id = letter["id"]
+ .as_str()
+ .filter(|id| id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit()))
+ .context("Invalid failure ID")?;
+ let identity = request["idempotencyKey"]
+ .as_str()
+ .filter(|id| !id.is_empty() && id.len() <= 256)
+ .context("An idempotencyKey of 1–256 bytes is required")?;
+ let body = letter.get("body").context("Missing original body")?;
+ let digest = format!("{:x}", Sha256::digest(serde_json::to_vec(body)?));
+ let producer = format!(
+ "rivet-redrive-{:x}",
+ Sha256::digest(serde_json::to_vec(&(self.actor_id(), identity))?)
+ );
+ let key = format!("stream_receipt_{producer}");
+ if let Some(raw) = internal_storage::load_meta_text(self.sql(), &key).await? {
+ let receipt =
+ StreamRedrive::deserialize_with_embedded_version(&URL_SAFE_NO_PAD.decode(raw)?)?;
+ ensure!(
+ receipt.failure_id == failure_id && receipt.body_digest == digest,
+ "Idempotency key was already used for different input"
+ );
+ if !receipt.next_offset.is_empty() {
+ return Ok(receipt_value(&receipt));
+ }
+ }
+ // Persist the identity before append so a crash cannot permit different input under the same key.
+ let pending = Receipt {
+ failure_id: failure_id.into(),
+ idempotency_key: identity.into(),
+ stream: connection.path.clone(),
+ next_offset: String::new(),
+ body_digest: digest.clone(),
+ created_at: now_ms(),
+ };
+ let bytes = StreamRedrive::wrap_latest(pending).serialize_with_embedded_version(VERSION)?;
+ internal_storage::persist_meta_text(self.sql(), &key, &URL_SAFE_NO_PAD.encode(bytes))
+ .await?;
+ let next_offset = connection
+ .append_once(&connection.path, &producer, body, Some(token))
+ .await?;
+ let receipt = Receipt {
+ failure_id: failure_id.into(),
+ idempotency_key: identity.into(),
+ stream: connection.path.clone(),
+ next_offset,
+ body_digest: digest,
+ created_at: now_ms(),
+ };
+ let value = receipt_value(&receipt);
+ let bytes = StreamRedrive::wrap_latest(receipt).serialize_with_embedded_version(VERSION)?;
+ internal_storage::persist_meta_text(self.sql(), &key, &URL_SAFE_NO_PAD.encode(bytes))
+ .await?;
+ Ok(value)
+ }
+}
+fn receipt_value(r: &Receipt) -> Value {
+ json!({"failureId":r.failure_id,"idempotencyKey":r.idempotency_key,"stream":r.stream,"nextOffset":r.next_offset,"createdAt":r.created_at})
+}
+
+#[cfg(test)]
+#[path = "../../../tests/unit/stream/dead_letter.rs"]
+mod tests;
diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/stream_subscription.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/stream_subscription.rs
new file mode 100644
index 0000000000..39ef1eed00
--- /dev/null
+++ b/rivetkit-rust/packages/rivetkit-core/src/actor/stream_subscription.rs
@@ -0,0 +1,195 @@
+//! Serializable routing declarations published in actor-type metadata.
+//!
+//! These describe destinations, not concrete DS consumers or processing cursors.
+//! Handlers, credentials and runtime validation schemas are never exported here.
+use anyhow::{Result, ensure};
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+use urlpattern::{UrlPattern, UrlPatternInit, UrlPatternMatchInput};
+
+/// A local handler's routing configuration; its actor name comes from registry ownership.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct NamedStreamSubscription {
+ pub stream: String,
+ pub actor_key: Vec,
+}
+
+impl NamedStreamSubscription {
+ fn pattern(&self) -> Result {
+ ensure!(
+ self.stream.starts_with('/') && self.stream.len() <= 2048,
+ "Stream pattern must be an absolute pathname of at most 2048 bytes"
+ );
+ Ok(UrlPattern::parse(
+ UrlPatternInit {
+ pathname: Some(self.stream.clone()),
+ ..Default::default()
+ },
+ Default::default(),
+ )?)
+ }
+
+ pub fn validate(&self) -> Result<()> {
+ let pattern = self.pattern()?;
+ for part in &self.actor_key {
+ if let Some(capture) = part.strip_prefix(':') {
+ ensure!(
+ pattern
+ .pathname
+ .group_name_list
+ .iter()
+ .any(|name| name == capture),
+ "Unknown stream capture: {capture}"
+ );
+ }
+ }
+ Ok(())
+ }
+
+ /// Match without interpolating captures back into URL syntax. Percent-encoded
+ /// path components stay encoded, matching URLPattern's pathname behavior.
+ pub fn resolve_key(&self, path: &str) -> Result>> {
+ let result = self
+ .pattern()?
+ .exec(UrlPatternMatchInput::Init(UrlPatternInit {
+ pathname: Some(format!("/{}", path.trim_start_matches('/'))),
+ ..Default::default()
+ }))?;
+ let Some(result) = result else {
+ return Ok(None);
+ };
+ self.actor_key
+ .iter()
+ .map(|part| {
+ if let Some(capture) = part.strip_prefix(':') {
+ result
+ .pathname
+ .groups
+ .get(capture)
+ .and_then(|value| value.clone())
+ .ok_or_else(|| anyhow::anyhow!("Missing stream capture: {capture}"))
+ } else {
+ Ok(part.clone())
+ }
+ })
+ .collect::>>()
+ .map(Some)
+ }
+}
+
+impl super::config::InputStreamConfig {
+ pub fn validate(&self) -> Result<()> {
+ for (name, subscription) in &self.subscriptions {
+ ensure!(
+ !name.is_empty()
+ && name != "primary"
+ && name.len() <= 128
+ && name
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'),
+ "Invalid or reserved stream subscription name: {name}"
+ );
+ subscription.validate()?;
+ }
+ for (name, config) in &self.dead_letters {
+ ensure!(
+ (name == "primary" && self.primary) || self.subscriptions.contains_key(name),
+ "Dead-letter handler is not configured"
+ );
+ if let Some(path) = &config.stream {
+ ensure!(
+ !path.is_empty()
+ && !path.starts_with('/')
+ && !path.starts_with("__ds/")
+ && !path.contains(['?', '#', '\\']),
+ "Invalid dead-letter stream path"
+ );
+ }
+ }
+
+ Ok(())
+ }
+
+ pub fn declarations(&self, actor_name: &str) -> BTreeMap {
+ let mut declarations: BTreeMap<_, _> = self
+ .subscriptions
+ .iter()
+ .map(|(name, sub)| {
+ (
+ name.clone(),
+ StreamSubscription {
+ stream: sub.stream.clone(),
+ destination: StreamDestination::ActorKey(ActorKeyDestination {
+ actor_name: actor_name.to_owned(),
+ key: sub.actor_key.clone(),
+ }),
+ },
+ )
+ })
+ .collect();
+ if self.primary {
+ declarations.insert("primary".into(), StreamSubscription::primary(actor_name));
+ }
+ declarations
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct StreamSubscription {
+ pub stream: String,
+ pub destination: StreamDestination,
+}
+
+/// Externally tagged so metadata contains exactly one destination variant.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub enum StreamDestination {
+ ActorId(ActorIdDestination),
+ ActorKey(ActorKeyDestination),
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct ActorIdDestination {
+ pub actor_name: String,
+ pub id: String,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct ActorKeyDestination {
+ pub actor_name: String,
+ pub key: Vec,
+}
+
+impl StreamSubscription {
+ pub(crate) fn primary(actor_name: &str) -> Self {
+ // URLPattern treats these characters as pattern syntax. The registered
+ // actor name is a literal path segment, even if it contains punctuation.
+ let mut url = url::Url::parse("https://actor.invalid/").expect("static URL");
+ url.path_segments_mut()
+ .expect("static URL path")
+ .clear()
+ .push(actor_name);
+ let mut literal = String::new();
+ for ch in url.path().trim_start_matches('/').chars() {
+ if matches!(ch, '+' | '*' | '?' | ':' | '{' | '}' | '(' | ')' | '\\') {
+ literal.push('\\');
+ }
+ literal.push(ch);
+ }
+ Self {
+ stream: format!("/actors/{literal}/:actorId"),
+ destination: StreamDestination::ActorId(ActorIdDestination {
+ actor_name: actor_name.to_owned(),
+ id: ":actorId".to_owned(),
+ }),
+ }
+ }
+}
+
+#[cfg(test)]
+#[path = "../../tests/unit/stream_subscription.rs"]
+mod tests;
diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/http.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/http.rs
index 42dd5ad2b9..a0a112e0cd 100644
--- a/rivetkit-rust/packages/rivetkit-core/src/registry/http.rs
+++ b/rivetkit-rust/packages/rivetkit-core/src/registry/http.rs
@@ -158,6 +158,174 @@ impl RegistryDispatcher {
) -> Result {
let actor = actor_specifier_for_instance(instance);
match route {
+ FrameworkHttpRoute::StreamRedrive => {
+ if request.method() != http::Method::POST {
+ return text_response(StatusCode::METHOD_NOT_ALLOWED, "Expected POST");
+ }
+ #[cfg(feature = "native-runtime")]
+ {
+ if request.body().len()
+ > instance.factory.config().max_incoming_message_size as usize
+ {
+ return text_response(StatusCode::PAYLOAD_TOO_LARGE, "Input too large");
+ }
+ let body = match serde_json::from_slice(request.body()) {
+ Ok(body) => body,
+ Err(_) => {
+ return text_response(
+ StatusCode::BAD_REQUEST,
+ "Expected a redrive request",
+ );
+ }
+ };
+ let token = request
+ .headers()
+ .get("x-rivet-token")
+ .and_then(|v| v.to_str().ok())
+ .or_else(|| {
+ request
+ .headers()
+ .get("authorization")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|v| v.strip_prefix("Bearer "))
+ });
+ match instance.ctx.stream_redrive_authenticated(body, token).await {
+ Ok(value) => bytes_response(
+ StatusCode::OK,
+ "application/json",
+ serde_json::to_vec(&value)?,
+ ),
+ Err(error) => framework_anyhow_error_response_with_actor(
+ request_encoding(request.headers()),
+ error,
+ Some(&actor),
+ ),
+ }
+ }
+ #[cfg(not(feature = "native-runtime"))]
+ text_response(
+ StatusCode::NOT_IMPLEMENTED,
+ "Actor streams require the native runtime",
+ )
+ }
+ FrameworkHttpRoute::StreamControl(resume) => {
+ if request.method() != http::Method::POST {
+ return text_response(StatusCode::METHOD_NOT_ALLOWED, "Expected POST");
+ }
+ #[cfg(feature = "native-runtime")]
+ {
+ let source = match serde_json::from_slice::(
+ request.body(),
+ ) {
+ Ok(source) => source,
+ Err(_) => {
+ return text_response(
+ StatusCode::BAD_REQUEST,
+ "Expected a stream source",
+ );
+ }
+ };
+ let result = if resume {
+ instance
+ .ctx
+ .stream_resume(source)
+ .await
+ .map(|_| serde_json::json!({"resumed":true}))
+ } else {
+ instance.ctx.stream_status(source).await
+ };
+ match result {
+ Ok(value) => bytes_response(
+ StatusCode::OK,
+ "application/json",
+ serde_json::to_vec(&value).expect("JSON status"),
+ ),
+ Err(error) => framework_anyhow_error_response_with_actor(
+ request_encoding(request.headers()),
+ error,
+ Some(&actor),
+ ),
+ }
+ }
+ #[cfg(not(feature = "native-runtime"))]
+ {
+ let _ = resume;
+ text_response(
+ StatusCode::NOT_IMPLEMENTED,
+ "Actor streams require the native runtime",
+ )
+ }
+ }
+
+ FrameworkHttpRoute::Stream(wake) => {
+ #[cfg(feature = "native-runtime")]
+ {
+ if request.method() != http::Method::POST {
+ return text_response(StatusCode::METHOD_NOT_ALLOWED, "Expected POST");
+ }
+ if request.body().len()
+ > instance.factory.config().max_incoming_message_size as usize
+ {
+ return text_response(StatusCode::PAYLOAD_TOO_LARGE, "Input too large");
+ }
+ let result = if wake {
+ instance
+ .ctx
+ .stream_wake(
+ request
+ .headers()
+ .get("webhook-signature")
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or(""),
+ request.body(),
+ )
+ .await
+ } else {
+ match serde_json::from_slice(request.body()) {
+ Ok(body) => {
+ let token = request
+ .headers()
+ .get("x-rivet-token")
+ .and_then(|v| v.to_str().ok())
+ .or_else(|| {
+ request
+ .headers()
+ .get("authorization")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|v| v.strip_prefix("Bearer "))
+ });
+ instance.ctx.stream_append_authenticated(body, token).await
+ }
+ Err(_) => {
+ return text_response(
+ StatusCode::BAD_REQUEST,
+ "Expected a JSON input",
+ );
+ }
+ }
+ };
+ match result {
+ Ok(()) => bytes_response(
+ StatusCode::ACCEPTED,
+ "application/json",
+ b"{\"done\":false}".to_vec(),
+ ),
+ Err(error) => framework_anyhow_error_response_with_actor(
+ request_encoding(request.headers()),
+ error,
+ Some(&actor),
+ ),
+ }
+ }
+ #[cfg(not(feature = "native-runtime"))]
+ {
+ let _ = wake;
+ text_response(
+ StatusCode::NOT_IMPLEMENTED,
+ "Actor streams require the native runtime",
+ )
+ }
+ }
FrameworkHttpRoute::Action(name) => {
self.handle_action_fetch(instance, request, name).await
}
@@ -449,6 +617,32 @@ impl RegistryHttpRoute {
normalized_path: &str,
handle_inspector_http_in_runtime: bool,
) -> Result {
+ let stream_path = normalized_path
+ .strip_prefix("/request/")
+ .map(|path| format!("/{path}"))
+ .unwrap_or_else(|| normalized_path.to_owned());
+ if matches!(
+ stream_path.as_str(),
+ "/internal/streams/append" | "/internal/streams/wake"
+ ) {
+ return Ok(Self::Framework(FrameworkHttpRoute::Stream(
+ stream_path.ends_with("/wake"),
+ )));
+ }
+ if matches!(
+ stream_path.as_str(),
+ "/internal/streams/status" | "/internal/streams/resume"
+ ) {
+ return Ok(Self::Framework(FrameworkHttpRoute::StreamControl(
+ stream_path.ends_with("/resume"),
+ )));
+ }
+ if stream_path == "/internal/streams/redrive" {
+ return Ok(Self::Framework(FrameworkHttpRoute::StreamRedrive));
+ }
+ if stream_path == "/internal" || stream_path.starts_with("/internal/") {
+ return Ok(Self::Framework(FrameworkHttpRoute::NotFound));
+ }
if let Some(stripped) = original_path.strip_prefix("/request") {
if stripped.is_empty() || matches!(stripped.as_bytes().first(), Some(b'/') | Some(b'?'))
{
@@ -480,6 +674,9 @@ impl RegistryHttpRoute {
}
pub(super) enum FrameworkHttpRoute {
+ Stream(bool),
+ StreamControl(bool),
+ StreamRedrive,
Action(String),
Queue(String),
Metadata,
@@ -1124,6 +1321,9 @@ pub(super) fn framework_anyhow_status(error: &anyhow::Error) -> StatusCode {
pub(super) fn framework_error_status(group: &str, code: &str) -> StatusCode {
match (group, code) {
+ ("stream", "invalid_wake") => StatusCode::UNAUTHORIZED,
+ ("stream", "busy" | "not_paused") => StatusCode::CONFLICT,
+ ("stream", "not_configured") => StatusCode::NOT_FOUND,
("auth", "forbidden") => StatusCode::FORBIDDEN,
("actor", "action_not_found") => StatusCode::NOT_FOUND,
("actor", "not_found") => StatusCode::NOT_FOUND,
diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs
index 47b9b967d7..c8c3cc8481 100644
--- a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs
+++ b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs
@@ -658,7 +658,7 @@ impl CoreRegistry {
pool_name: config.pool_name,
prepopulate_actor_names,
metadata: Some(json!({
- "rivetkit": { "version": config.serverless_package_version },
+ "rivetkit": { "version": config.serverless_package_version, "streamSubscriptions": true },
})),
not_global: false,
debug_latency_ms: None,
@@ -724,6 +724,11 @@ impl CoreRegistry {
self,
config: ServeConfig,
) -> Result {
+ for factory in self.factories.values() {
+ if let Some(stream) = &factory.config().input_stream {
+ stream.validate()?;
+ }
+ }
crate::serverless::CoreServerlessRuntime::new(self.factories, config).await
}
}
@@ -766,6 +771,7 @@ pub(crate) fn serverless_metadata_payload(
.collect::>();
ServerlessMetadataPayload {
+ stream_subscriptions: true,
runtime: "rivetkit".to_owned(),
version: config.serverless_package_version.clone(),
envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
@@ -795,6 +801,14 @@ fn build_actor_metadata_map_from_factories(
if let Some(name) = &config.name {
metadata.insert("name".to_owned(), json!(name));
}
+ let subscriptions = config
+ .input_stream
+ .as_ref()
+ .map(|stream| stream.declarations(actor_name))
+ .unwrap_or_default();
+ // Always export an explicit set, including empty, so a metadata
+ // consumer can distinguish removal from an older unaware publisher.
+ metadata.insert("streamSubscriptions".to_owned(), json!(subscriptions));
(actor_name.clone(), JsonValue::Object(metadata))
})
.collect()
diff --git a/rivetkit-rust/packages/rivetkit-core/src/serverless.rs b/rivetkit-rust/packages/rivetkit-core/src/serverless.rs
index a0c45ca061..6de3a0ef81 100644
--- a/rivetkit-rust/packages/rivetkit-core/src/serverless.rs
+++ b/rivetkit-rust/packages/rivetkit-core/src/serverless.rs
@@ -468,6 +468,7 @@ impl CoreServerlessRuntime {
.collect::>();
let payload = ServerlessMetadataPayload {
+ stream_subscriptions: true,
runtime: "rivetkit".to_owned(),
version: self.settings.package_version.clone(),
envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
@@ -580,7 +581,7 @@ impl CoreServerlessRuntime {
pool_name: headers.pool_name.clone(),
prepopulate_actor_names,
metadata: Some(json!({
- "rivetkit": { "version": self.settings.package_version },
+ "rivetkit": { "version": self.settings.package_version, "streamSubscriptions": true },
})),
not_global: true,
debug_latency_ms: None,
diff --git a/rivetkit-rust/packages/rivetkit-core/tests/registry.rs b/rivetkit-rust/packages/rivetkit-core/tests/registry.rs
index 05564197f3..fc0b129b60 100644
--- a/rivetkit-rust/packages/rivetkit-core/tests/registry.rs
+++ b/rivetkit-rust/packages/rivetkit-core/tests/registry.rs
@@ -20,6 +20,56 @@ fn actor_metadata_does_not_request_legacy_kv_preload_probes() {
);
}
+#[test]
+fn actor_metadata_exports_primary_subscription_without_activating_actor() {
+ let mut factories = HashMap::new();
+ let config = ActorConfig {
+ name: Some("Customer display name".into()),
+ input_stream: Some(crate::actor::config::InputStreamConfig {
+ dead_letters: Default::default(),
+ primary: true,
+ subscriptions: Default::default(),
+ endpoint: "https://streams.gameinc.io/v1/stream".into(),
+ token: Some("test-management-secret".into()),
+ }),
+ ..Default::default()
+ };
+ factories.insert(
+ "customer".to_owned(),
+ Arc::new(ActorFactory::new(config, |_| {
+ panic!("metadata publication must not start actors")
+ })),
+ );
+ let metadata = build_actor_metadata_map_from_factories(&factories);
+ assert_eq!(
+ metadata["customer"]["streamSubscriptions"],
+ json!({
+ "primary": {
+ "stream": "/actors/customer/:actorId",
+ "destination": {"actorId": {"actorName": "customer", "id": ":actorId"}}
+ }
+ })
+ );
+ assert_eq!(metadata["customer"]["name"], "Customer display name");
+ assert!(
+ !serde_json::to_string(&metadata)
+ .unwrap()
+ .contains("test-management-secret")
+ );
+}
+
+#[test]
+fn actor_metadata_exports_empty_subscription_set_for_removal() {
+ let factories = HashMap::from([(
+ "customer".into(),
+ Arc::new(ActorFactory::new(ActorConfig::default(), |_| {
+ panic!("metadata publication must not start actors")
+ })),
+ )]);
+ let metadata = build_actor_metadata_map_from_factories(&factories);
+ assert_eq!(metadata["customer"]["streamSubscriptions"], json!({}));
+}
+
fn is_actor_active(state: Option<&ActorInstanceState>) -> bool {
matches!(state, Some(ActorInstanceState::Active(_)))
}
diff --git a/rivetkit-rust/packages/rivetkit-core/tests/registry_http.rs b/rivetkit-rust/packages/rivetkit-core/tests/registry_http.rs
index 037476c708..deb69bd380 100644
--- a/rivetkit-rust/packages/rivetkit-core/tests/registry_http.rs
+++ b/rivetkit-rust/packages/rivetkit-core/tests/registry_http.rs
@@ -423,3 +423,20 @@ mod moved_tests {
assert_eq!(decoded.metadata, None);
}
}
+
+#[test]
+fn internal_stream_paths_are_framework_owned_even_under_raw_requests() {
+ for prefix in ["", "/request"] {
+ for (name, wake) in [("append", false), ("wake", true)] {
+ let path = format!("{prefix}/internal/streams/{name}");
+ assert!(
+ matches!(RegistryHttpRoute::from_paths(&path,&path,false).unwrap(), RegistryHttpRoute::Framework(FrameworkHttpRoute::Stream(value)) if value == wake)
+ );
+ }
+ let path = format!("{prefix}/internal/user-handler");
+ assert!(matches!(
+ RegistryHttpRoute::from_paths(&path, &path, false).unwrap(),
+ RegistryHttpRoute::Framework(FrameworkHttpRoute::NotFound)
+ ));
+ }
+}
diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs b/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs
index faa559e36f..b5f4792bdf 100644
--- a/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs
+++ b/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs
@@ -102,6 +102,14 @@ fn fixture(row_count: usize) -> Connection {
)
.expect("seed metadata");
+ for i in 0..1024 {
+ tx.execute(
+ "INSERT INTO _rivet_meta (key,value) VALUES (?,x'03')",
+ [format!("stream_checkpoint_v1_{i:064x}")],
+ )
+ .expect("seed stream progress cardinality");
+ }
+
let mut conn_insert = tx
.prepare("INSERT INTO _rivet_conns (conn_id, parameters, gateway_id, request_id, request_path, request_headers) VALUES (?, x'01', x'00000000', x'00000000', '/', x'01')")
.expect("prepare connection seed");
@@ -497,6 +505,12 @@ fn query_catalog() -> Vec {
params: vec![text("fixture")],
expectation: indexed(None, &["_rivet_meta"]),
},
+ QueryCase {
+ id: "stream.checkpoint",
+ sql: internal_storage::LOAD_META_TEXT_SQL.into(),
+ params: vec![text("stream_checkpoint_v1_source_digest")],
+ expectation: indexed(None, &["_rivet_meta"]),
+ },
QueryCase {
id: "schedule.cancel",
sql: queries::CANCEL_SCHEDULE_SQL.into(),
diff --git a/rivetkit-rust/packages/rivetkit-core/tests/unit/stream.rs b/rivetkit-rust/packages/rivetkit-core/tests/unit/stream.rs
new file mode 100644
index 0000000000..072926520d
--- /dev/null
+++ b/rivetkit-rust/packages/rivetkit-core/tests/unit/stream.rs
@@ -0,0 +1,227 @@
+use super::*;
+use axum::{Json, Router, routing::get};
+use ed25519_dalek::{Signer, SigningKey};
+
+#[tokio::test]
+async fn stream_wake_requires_trusted_signature_freshness_and_destination_binding() {
+ let signer = SigningKey::from_bytes(&[23; 32]);
+ let public = URL_SAFE_NO_PAD.encode(signer.verifying_key().to_bytes());
+ let jwks = json!({"keys":[{"kid":"test-key","kty":"OKP","crv":"Ed25519","x":public}]});
+ let app = Router::new().route(
+ "/streams/__ds/jwks.json",
+ get(move || async move { Json(jwks) }),
+ );
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let root = format!("http://{}/streams", listener.local_addr().unwrap());
+ let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+ let connection = Connection {
+ dead_letters: Default::default(),
+ primary: true,
+ actor_key: vec![],
+ subscriptions: BTreeMap::new(),
+ client: Client::new(),
+ root: url::Url::parse(&root).unwrap(),
+ token: None,
+ path: "actors/worker/actor-1".into(),
+ subscription: "actor-actor-1".into(),
+ };
+ let wake = json!({"subscription_id":"actor-actor-1","wake_id":"wake-1","generation":1,
+ "streams":[{"path":"actors/worker/actor-1","acked_offset":"0000","tail_offset":"0001"}],
+ "callback_url":format!("{root}/__ds/subscriptions/actor-actor-1/callback"),"callback_token":"capability"});
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+ let sign = |key: &SigningKey, time: u64, value: &Value| {
+ let body = serde_json::to_vec(value).unwrap();
+ let mut signed = format!("{time}.").into_bytes();
+ signed.extend_from_slice(&body);
+ let signature = URL_SAFE_NO_PAD.encode(key.sign(&signed).to_bytes());
+ (format!("t={time},kid=test-key,ed25519={signature}"), body)
+ };
+ let (header, body) = sign(&signer, now, &wake);
+ assert!(connection.verify(&header, &body).await.is_ok());
+ // Identical wakes are valid redeliveries; the durable checkpoint handles duplicates.
+ assert!(connection.verify(&header, &body).await.is_ok());
+ assert!(
+ connection
+ .verify(&format!("{header},kid=test-key"), &body)
+ .await
+ .is_err()
+ );
+ assert!(connection.verify(&header, b"{}").await.is_err());
+ for time in [now - 301, now + 301] {
+ let (header, body) = sign(&signer, time, &wake);
+ assert!(connection.verify(&header, &body).await.is_err());
+ }
+ let (header, body) = sign(&SigningKey::from_bytes(&[42; 32]), now, &wake);
+ assert!(connection.verify(&header, &body).await.is_err());
+ for field in [
+ "subscription",
+ "stream",
+ "callback",
+ "multiple",
+ "generation",
+ ] {
+ let mut wrong = wake.clone();
+ match field {
+ "subscription" => wrong["subscription_id"] = json!("actor-other"),
+ "stream" => wrong["streams"][0]["path"] = json!("actors/worker/other"),
+ "callback" => wrong["callback_url"] = json!("https://other.gameinc.io/callback"),
+ "multiple" => {
+ wrong["streams"]
+ .as_array_mut()
+ .unwrap()
+ .push(wake["streams"][0].clone());
+ }
+ _ => wrong["generation"] = json!(-1),
+ }
+ let (header, body) = sign(&signer, now, &wrong);
+ assert!(
+ connection.verify(&header, &body).await.is_err(),
+ "accepted wrong {field}"
+ );
+ }
+ server.abort();
+}
+
+#[test]
+fn named_wakes_bind_all_sources_to_handler_and_actor_key() {
+ let connection = Connection {
+ dead_letters: Default::default(),
+ client: Client::new(),
+ root: url::Url::parse("https://streams.gameinc.io/streams").unwrap(),
+ token: None,
+ primary: false,
+ path: "actors/customer/actor-1".into(),
+ subscription: "actor-actor-1".into(),
+ actor_key: vec![crate::types::ActorKeySegment::String("acme".into())],
+ subscriptions: BTreeMap::from([(
+ "orders".into(),
+ NamedStreamSubscription {
+ stream: "/orders/:tenant/:source".into(),
+ actor_key: vec![":tenant".into()],
+ },
+ )]),
+ };
+ let mut wake: Wake=serde_json::from_value(json!({"subscription_id":"actor-actor-1-orders","wake_id":"w","generation":1,"streams":[{"path":"orders/acme/a","acked_offset":"0","tail_offset":"1"},{"path":"orders/acme/b","acked_offset":"0","tail_offset":"1"}],"callback_url":"","callback_token":""})).unwrap();
+ assert_eq!(connection.validate_destination(&wake).unwrap(), "orders");
+ wake.streams[1].path = "orders/other/b".into();
+ assert!(connection.validate_destination(&wake).is_err());
+ wake.streams[1].path = "orders/acme/a".into();
+ assert!(connection.validate_destination(&wake).is_err());
+ wake.streams.pop();
+ wake.subscription_id = "actor-actor-1-missing".into();
+ assert!(connection.validate_destination(&wake).is_err());
+ assert_eq!(
+ connection
+ .source_request(Method::GET, "orders/acme/a%2Fb")
+ .unwrap()
+ .build()
+ .unwrap()
+ .url()
+ .path(),
+ "/streams/orders/acme/a%2Fb"
+ );
+ assert!(connection.source_request(Method::GET, "../escape").is_err());
+}
+
+#[test]
+fn caller_append_never_adds_the_process_credential() {
+ let connection = Connection {
+ dead_letters: Default::default(),
+ primary: true,
+ actor_key: Default::default(),
+ subscriptions: Default::default(),
+ client: Client::new(),
+ root: url::Url::parse("https://streams.gameinc.io/streams").unwrap(),
+ token: Some("process-secret".into()),
+ path: "actors/worker/id".into(),
+ subscription: "actor-id".into(),
+ };
+ let request = connection
+ .source_request_with_token(Method::POST, &connection.path, Some("caller-token"))
+ .unwrap()
+ .build()
+ .unwrap();
+ let values = request
+ .headers()
+ .get_all("authorization")
+ .iter()
+ .collect::>();
+ assert_eq!(values.len(), 1);
+ assert_eq!(values[0], "Bearer caller-token");
+ let read = connection
+ .source_request(Method::GET, &connection.path)
+ .unwrap()
+ .build()
+ .unwrap();
+ assert_eq!(read.headers()["authorization"], "Bearer process-secret");
+}
+
+#[test]
+fn failures_preserve_budget_deadline_and_pause_across_checkpoint_reload() {
+ let legacy = r#"{"offset":"0007","completed":1}"#;
+ let mut checkpoint = Checkpoint::decode(legacy).unwrap();
+ for attempt in 1..=6 {
+ checkpoint.failed(&anyhow::anyhow!("poison record"), 10_000 + attempt as i64);
+ let encoded = checkpoint.encode().unwrap();
+ assert!(encoded.starts_with("bare:"));
+ checkpoint = Checkpoint::decode(&encoded).unwrap();
+ let failure = checkpoint.failure.as_ref().unwrap();
+ assert_eq!(failure.attempts, attempt);
+ assert_eq!(failure.first_failed_at, 10_001);
+ assert_eq!(failure.paused, attempt == 6);
+ assert_eq!(checkpoint.offset, "0007");
+ assert_eq!(checkpoint.completed, 1);
+ if attempt == 1 {
+ assert_eq!(failure.next_attempt_at, 11_001);
+ }
+ if attempt == 5 {
+ assert_eq!(failure.next_attempt_at, 26_005);
+ }
+ }
+ let mut restored = Checkpoint::decode(&checkpoint.encode().unwrap()).unwrap();
+ restored.failure = None; // Explicit resume retains position and resets only the budget.
+ restored.failed(&anyhow::anyhow!("still failing"), 99_000);
+ assert_eq!(restored.failure.unwrap().attempts, 1);
+ assert!(Checkpoint::decode("bare:invalid").is_err());
+}
+
+#[test]
+fn checkpoint_upgrade_and_pending_handoff_preserve_payload_and_producer_identity() {
+ let bytes = StreamCheckpoint::wrap_latest(wire::Data {
+ offset: "001".into(),
+ completed: 2,
+ failure: None,
+ dead_letter: None,
+ })
+ .serialize_with_embedded_version(1)
+ .unwrap();
+ let mut checkpoint =
+ Checkpoint::decode(&format!("bare:{}", URL_SAFE_NO_PAD.encode(bytes))).unwrap();
+ assert!(checkpoint.dead_letter.is_none());
+ checkpoint.dead_letter = Some(wire::DeadLetter {
+ stream: "dead-letters/a".into(),
+ producer_id: "stable".into(),
+ envelope: br#"{"body":{"id":8}}"#.to_vec(),
+ delivery_error: Some("DS unavailable".into()),
+ });
+ let restored = Checkpoint::decode(&checkpoint.encode().unwrap()).unwrap();
+ assert_eq!(restored.offset, "001");
+ assert_eq!(restored.completed, 2);
+ let pending = restored.dead_letter.unwrap();
+ assert_eq!(pending.producer_id, "stable");
+ assert_eq!(pending.envelope, br#"{"body":{"id":8}}"#);
+ assert_eq!(pending.delivery_error.as_deref(), Some("DS unavailable"));
+ assert!(
+ StreamCheckpoint::wrap_latest(wire::Data {
+ offset: "001".into(),
+ completed: 2,
+ failure: None,
+ dead_letter: Some(pending)
+ })
+ .serialize_with_embedded_version(1)
+ .is_err()
+ );
+}
diff --git a/rivetkit-rust/packages/rivetkit-core/tests/unit/stream/dead_letter.rs b/rivetkit-rust/packages/rivetkit-core/tests/unit/stream/dead_letter.rs
new file mode 100644
index 0000000000..e0fc5120b1
--- /dev/null
+++ b/rivetkit-rust/packages/rivetkit-core/tests/unit/stream/dead_letter.rs
@@ -0,0 +1,128 @@
+use super::*;
+use axum::{
+ Json, Router,
+ extract::State,
+ http::{HeaderMap, StatusCode},
+ routing::put,
+};
+use std::sync::{Arc, Mutex};
+
+#[tokio::test]
+async fn pending_handoff_survives_failure_and_uncertain_publication_without_skipping_input() {
+ #[derive(Default)]
+ struct Store {
+ received: Vec,
+ calls: usize,
+ producer: Option,
+ }
+ let state = Arc::new(Mutex::new(Store::default()));
+ async fn append(
+ State(state): State>>,
+ headers: HeaderMap,
+ Json(body): Json,
+ ) -> (StatusCode, HeaderMap) {
+ assert_eq!(headers["authorization"], "Bearer process-secret");
+ assert_eq!(headers["producer-epoch"], "0");
+ assert_eq!(headers["producer-seq"], "0");
+ let producer = headers["producer-id"].to_str().unwrap();
+ let mut state = state.lock().unwrap();
+ state.calls += 1;
+ if let Some(previous) = &state.producer {
+ assert_eq!(previous, producer);
+ } else {
+ state.producer = Some(producer.into());
+ state.received.push(body);
+ }
+ let mut headers = HeaderMap::new();
+ headers.insert("stream-next-offset", "001".parse().unwrap());
+ (
+ if state.calls == 1 {
+ StatusCode::SERVICE_UNAVAILABLE
+ } else {
+ StatusCode::OK
+ },
+ headers,
+ )
+ }
+ let app = Router::new()
+ .route(
+ "/streams/dlq",
+ put(|| async { StatusCode::OK }).post(append),
+ )
+ .with_state(state.clone());
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let root = url::Url::parse(&format!(
+ "http://{}/streams",
+ listener.local_addr().unwrap()
+ ))
+ .unwrap();
+ let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+ let connection = Connection {
+ primary: true,
+ dead_letters: Default::default(),
+ actor_key: Default::default(),
+ subscriptions: Default::default(),
+ client: Client::new(),
+ root,
+ token: Some("process-secret".into()),
+ path: "actors/a/id".into(),
+ subscription: "actor-id".into(),
+ };
+ let ctx = ActorContext::new("id", "a", vec![], "local");
+ crate::actor::internal_storage::schema::ensure_internal_schema(ctx.sql())
+ .await
+ .unwrap();
+ let mut checkpoint = Checkpoint::decode(r#"{"offset":"000","completed":1}"#).unwrap();
+ for _ in 0..6 {
+ checkpoint.failed(&anyhow::anyhow!("poison"), 100);
+ }
+ ctx.prepare_dead_letter(
+ &mut checkpoint,
+ "checkpoint",
+ "primary",
+ "actors/a/id",
+ "dlq".into(),
+ &json!({"id":8}),
+ )
+ .await
+ .unwrap();
+ assert!(
+ !ctx.deliver_dead_letter(&connection, &mut checkpoint, "checkpoint")
+ .await
+ .unwrap()
+ );
+ let raw = internal_storage::load_meta_text(ctx.sql(), "checkpoint")
+ .await
+ .unwrap()
+ .unwrap();
+ let mut restored = Checkpoint::decode(&raw).unwrap();
+ assert!(
+ restored
+ .dead_letter
+ .as_ref()
+ .unwrap()
+ .delivery_error
+ .is_some()
+ );
+ assert_eq!(restored.completed, 1);
+ assert_eq!(restored.offset, "000");
+ assert_eq!(restored.failure.as_ref().unwrap().attempts, 6);
+ assert!(
+ ctx.deliver_dead_letter(&connection, &mut restored, "checkpoint")
+ .await
+ .unwrap()
+ );
+ // Simulate a crash after acceptance but before checkpoint advancement.
+ let mut restored = Checkpoint::decode(&raw).unwrap();
+ assert!(
+ ctx.deliver_dead_letter(&connection, &mut restored, "checkpoint")
+ .await
+ .unwrap()
+ );
+ assert_eq!(restored.completed, 1);
+ let stored = state.lock().unwrap();
+ assert_eq!(stored.calls, 3);
+ assert_eq!(stored.received.len(), 1);
+ assert_eq!(stored.received[0][0]["body"], json!({"id":8}));
+ server.abort();
+}
diff --git a/rivetkit-rust/packages/rivetkit-core/tests/unit/stream_subscription.rs b/rivetkit-rust/packages/rivetkit-core/tests/unit/stream_subscription.rs
new file mode 100644
index 0000000000..b7777dabdb
--- /dev/null
+++ b/rivetkit-rust/packages/rivetkit-core/tests/unit/stream_subscription.rs
@@ -0,0 +1,97 @@
+use super::*;
+use serde_json::json;
+
+#[test]
+fn destinations_use_the_agreed_externally_tagged_objects() {
+ let primary = StreamSubscription::primary("customer");
+ assert_eq!(
+ serde_json::to_value(&primary).unwrap(),
+ json!({
+ "stream": "/actors/customer/:actorId",
+ "destination": {"actorId": {"actorName": "customer", "id": ":actorId"}}
+ })
+ );
+ let custom = json!({
+ "stream": "/tenants/:tenant/orders/:customer",
+ "destination": {"actorKey": {"actorName": "customer", "key": [":tenant", ":customer"]}}
+ });
+ let parsed: StreamSubscription = serde_json::from_value(custom.clone()).unwrap();
+ assert_eq!(serde_json::to_value(parsed).unwrap(), custom);
+}
+
+#[test]
+fn destinations_reject_ambiguous_or_underspecified_shapes() {
+ for destination in [
+ json!({"actorId": "123"}),
+ json!({"actorId": {"id": "123"}}),
+ json!({"actorKey": {"actorName": "customer", "key": "123"}}),
+ json!({"actorId": {"actorName": "customer", "id": "123"}, "actorKey": {"actorName": "customer", "key": ["123"]}}),
+ json!({"actorId": {"actorName": "customer", "id": "123", "token": "must-not-be-metadata"}}),
+ ] {
+ assert!(serde_json::from_value::(destination).is_err());
+ }
+}
+
+#[test]
+fn primary_actor_names_are_literal_in_the_pattern() {
+ assert_eq!(
+ StreamSubscription::primary("customer(v2)").stream,
+ "/actors/customer\\(v2\\)/:actorId"
+ );
+}
+
+#[test]
+fn named_patterns_validate_captures_and_match_whole_paths() {
+ let sub = NamedStreamSubscription {
+ stream: "/tenants/:tenant/orders/:customer".into(),
+ actor_key: vec![":tenant".into(), "literal".into(), ":customer".into()],
+ };
+ sub.validate().unwrap();
+ assert_eq!(
+ sub.resolve_key("tenants/acme/orders/alice").unwrap(),
+ Some(vec!["acme".into(), "literal".into(), "alice".into()])
+ );
+ assert_eq!(
+ sub.resolve_key("tenants/acme/orders/alice/extra").unwrap(),
+ None
+ );
+ assert_eq!(
+ sub.resolve_key("tenants/a%2Fb/orders/alice")
+ .unwrap()
+ .unwrap()[0],
+ "a%2Fb"
+ );
+ let invalid = NamedStreamSubscription {
+ actor_key: vec![":missing".into()],
+ ..sub
+ };
+ assert!(invalid.validate().is_err());
+}
+
+#[test]
+fn named_config_reserves_primary_and_exports_without_primary_handler() {
+ let mut config = super::super::config::InputStreamConfig {
+ dead_letters: Default::default(),
+ endpoint: "https://streams.gameinc.io".into(),
+ token: None,
+ primary: false,
+ subscriptions: BTreeMap::from([(
+ "orders".into(),
+ NamedStreamSubscription {
+ stream: "/orders/:id".into(),
+ actor_key: vec![":id".into()],
+ },
+ )]),
+ };
+ config.validate().unwrap();
+ let exported = config.declarations("customer");
+ assert!(!exported.contains_key("primary"));
+ assert_eq!(
+ serde_json::to_value(&exported["orders"]).unwrap()["destination"]["actorKey"]["actorName"],
+ "customer"
+ );
+ config
+ .subscriptions
+ .insert("primary".into(), config.subscriptions["orders"].clone());
+ assert!(config.validate().is_err());
+}
diff --git a/rivetkit-rust/packages/rivetkit/src/actor.rs b/rivetkit-rust/packages/rivetkit/src/actor.rs
index c11b5c71f7..d3fdea5b2c 100644
--- a/rivetkit-rust/packages/rivetkit/src/actor.rs
+++ b/rivetkit-rust/packages/rivetkit/src/actor.rs
@@ -37,6 +37,9 @@ pub trait Actor: Send + Sync + Sized + 'static {
type Input: DeserializeOwned + Default + Send + 'static;
type Actions: ActionSet;
type Events: EventSet;
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
type Queue: QueueSet;
type ConnParams: DeserializeOwned + Default + Send + Sync + 'static;
type ConnState: Serialize + DeserializeOwned + Default + Send + Sync + Clone + 'static;
@@ -44,6 +47,35 @@ pub trait Actor: Send + Sync + Sized + 'static {
const HAS_DATABASE: bool = false;
+ /// Handles one JSON input record when ActorConfig::input_stream is configured.
+ /// Dispatch a configured named subscription; primary keeps the original handler.
+ async fn on_subscription_message(
+ &self,
+ ctx: &Ctx,
+ subscription: &str,
+ body: serde_json::Value,
+ ) -> Result<()> {
+ anyhow::ensure!(
+ subscription == "primary",
+ "No handler for stream subscription: {subscription}"
+ );
+ self.on_stream_message(ctx, body).await
+ }
+
+ async fn on_stream_message(&self, _ctx: &Ctx, _body: serde_json::Value) -> Result<()> {
+ bail!("stream.onMessage is not configured")
+ }
+
+ /// Called when a source exhausts its handler retries and pauses.
+ async fn on_stream_error(
+ &self,
+ _ctx: &Ctx,
+ _subscription: &str,
+ _failure: serde_json::Value,
+ ) -> Result<()> {
+ Ok(())
+ }
+
/// Opts this actor into concurrent typed HTTP callbacks.
const CONCURRENT_HTTP_CALLBACKS: bool = false;
diff --git a/rivetkit-rust/packages/rivetkit/src/context.rs b/rivetkit-rust/packages/rivetkit/src/context.rs
index 1e7319fb89..2fef4910c6 100644
--- a/rivetkit-rust/packages/rivetkit/src/context.rs
+++ b/rivetkit-rust/packages/rivetkit/src/context.rs
@@ -168,6 +168,37 @@ impl Clone for Ctx {
}
impl Ctx {
+ pub async fn stream_status(
+ &self,
+ source: rivetkit_core::actor::stream::StreamSource,
+ ) -> Result {
+ self.inner.stream_status(source).await
+ }
+ pub async fn stream_redrive(
+ &self,
+ message: serde_json::Value,
+ idempotency_key: &str,
+ ) -> Result {
+ let mut bytes = Vec::new();
+ ciborium::into_writer(
+ &serde_json::json!({"message":message,"idempotencyKey":idempotency_key}),
+ &mut bytes,
+ )?;
+ Ok(ciborium::from_reader(
+ self.inner.stream_redrive_cbor(&bytes).await?.as_slice(),
+ )?)
+ }
+ pub async fn stream_resume(
+ &self,
+ source: rivetkit_core::actor::stream::StreamSource,
+ ) -> Result<()> {
+ self.inner.stream_resume(source).await
+ }
+ /// Append one JSON record to this actor's durable input stream.
+ pub async fn stream_append(&self, body: serde_json::Value) -> Result<()> {
+ self.inner.stream_append(body).await
+ }
+
pub fn new(inner: ActorContext) -> Self {
Self {
inner,
@@ -296,6 +327,9 @@ impl Ctx {
self.inner.sql()
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn queue(&self) -> Queue<'_, A> {
Queue::new(&self.inner)
}
diff --git a/rivetkit-rust/packages/rivetkit/src/event.rs b/rivetkit-rust/packages/rivetkit/src/event.rs
index de6cd87836..eb657a0a1d 100644
--- a/rivetkit-rust/packages/rivetkit/src/event.rs
+++ b/rivetkit-rust/packages/rivetkit/src/event.rs
@@ -77,6 +77,18 @@ impl_event_set!(
#[derive(Debug)]
#[must_use = "dropping a RuntimeEvent without replying sends actor/dropped_reply"]
pub enum RuntimeEvent {
+ StreamError {
+ subscription: String,
+ body: Vec,
+ cancel: tokio_util::sync::CancellationToken,
+ reply: Reply<()>,
+ },
+ StreamMessage {
+ subscription: String,
+ body: Vec,
+ cancel: tokio_util::sync::CancellationToken,
+ reply: Reply<()>,
+ },
Action(ActionCall),
Http(HttpCall),
QueueSend(QueueSend ),
@@ -92,6 +104,29 @@ pub enum RuntimeEvent {
impl RuntimeEvent {
pub(crate) fn from_core(event: ActorEvent) -> Self {
match event {
+ ActorEvent::StreamError {
+ subscription,
+ body,
+ cancel,
+ reply,
+ } => Self::StreamError {
+ subscription,
+ body,
+ cancel,
+ reply,
+ },
+
+ ActorEvent::StreamMessage {
+ subscription,
+ body,
+ cancel,
+ reply,
+ } => Self::StreamMessage {
+ subscription,
+ body,
+ cancel,
+ reply,
+ },
ActorEvent::Action {
name,
args,
diff --git a/rivetkit-rust/packages/rivetkit/src/lib.rs b/rivetkit-rust/packages/rivetkit/src/lib.rs
index eaf24648bb..4eb71ac6a7 100644
--- a/rivetkit-rust/packages/rivetkit/src/lib.rs
+++ b/rivetkit-rust/packages/rivetkit/src/lib.rs
@@ -47,11 +47,45 @@ pub use rivetkit_core::serverless::{
pub use rivetkit_core::serverless_http;
pub use rivetkit_core::{
ActorConfig, ActorHttpResponse, ActorKey, ActorKeySegment, ActorKv, ActorTracingConfig,
- CanHibernateWebSocket, CompletableQueueMessage, ConnHandle, ConnId, EngineSpawnMode,
- EnqueueAndWaitOpts, HTTP_BODY_STREAM_CHANNEL_CAPACITY, KeepAwakeRegion, ListOpts,
- QueueMessage as CoreQueueMessage, QueueNextBatchOpts, QueueNextOpts, QueueTryNextBatchOpts,
- QueueTryNextOpts, QueueWaitOpts, Request, RequestSaveOpts, Response, ResponseChunk,
- RuntimeMode, SaveStateOpts, SerializeStateReason, ServeConfig, SqliteBatchStatement, SqliteDb,
+ CanHibernateWebSocket, ConnHandle, ConnId, EngineSpawnMode, HTTP_BODY_STREAM_CHANNEL_CAPACITY,
+ KeepAwakeRegion, ListOpts, Request, RequestSaveOpts, Response, ResponseChunk, RuntimeMode,
+ SaveStateOpts, SerializeStateReason, ServeConfig, SqliteBatchStatement, SqliteDb,
SqliteTransaction, StateDelta, StreamingResponse, WebSocket, WsMessage,
sqlite::{BindParam, ColumnValue, ExecResult, QueryResult},
};
+
+pub use rivetkit_core::actor::stream::StreamSource;
+
+// Compatibility aliases keep legacy queue types available without changing behavior.
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+pub type CompletableQueueMessage = rivetkit_core::CompletableQueueMessage;
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+pub type EnqueueAndWaitOpts = rivetkit_core::EnqueueAndWaitOpts;
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+pub type CoreQueueMessage = rivetkit_core::QueueMessage;
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+pub type QueueNextBatchOpts = rivetkit_core::QueueNextBatchOpts;
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+pub type QueueNextOpts = rivetkit_core::QueueNextOpts;
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+pub type QueueTryNextBatchOpts = rivetkit_core::QueueTryNextBatchOpts;
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+pub type QueueTryNextOpts = rivetkit_core::QueueTryNextOpts;
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
+pub type QueueWaitOpts = rivetkit_core::QueueWaitOpts;
diff --git a/rivetkit-rust/packages/rivetkit/src/queue.rs b/rivetkit-rust/packages/rivetkit/src/queue.rs
index 22928a1455..926db92528 100644
--- a/rivetkit-rust/packages/rivetkit/src/queue.rs
+++ b/rivetkit-rust/packages/rivetkit/src/queue.rs
@@ -14,12 +14,18 @@ use serde::{Serialize, de::DeserializeOwned};
use crate::{actor::Actor, context::Ctx};
pub(crate) type BoxQueueFuture = Pin>>> + Send>>;
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub trait QueueMessage: Serialize + DeserializeOwned + Send + Sync + 'static {
type Reply: Serialize + DeserializeOwned + Send + 'static;
const NAME: &'static str;
}
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub trait HandlesQueue: Actor + Sized {
type Future: Future> + Send + 'static;
@@ -27,12 +33,16 @@ pub trait HandlesQueue: Actor + Sized {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub struct QueueEntry {
pub name: &'static str,
_p: PhantomData A>,
}
impl QueueEntry {
+ #[deprecated(note = "Queues are deprecated. Use actor streams.")]
pub const fn new(name: &'static str) -> Self {
Self {
name,
@@ -41,6 +51,9 @@ impl QueueEntry {
}
}
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub trait QueueSet: Send + Sync + 'static {
fn entries() -> Vec>;
fn dispatch(actor: Arc, ctx: Ctx , name: &str, body: &[u8]) -> Option;
@@ -118,55 +131,91 @@ impl_queue_set!(
/// [`QueueMessage`] bodies are raw bytes that the caller decodes against their own
/// schema, matching how queued messages arrive through the event loop as
/// `RuntimeEvent::QueueSend`.
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub struct Queue<'a, A: Actor> {
inner: &'a ActorContext,
_p: PhantomData A>,
}
#[derive(Debug, Clone)]
+#[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+)]
pub struct TypedQueueMessage {
inner: CoreQueueMessage,
body: M,
}
impl TypedQueueMessage {
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn id(&self) -> u64 {
self.inner.id
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn name(&self) -> &str {
&self.inner.name
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn body(&self) -> &M {
&self.body
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn created_at(&self) -> i64 {
self.inner.created_at
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn is_completable(&self) -> bool {
self.inner.is_completable()
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn into_body(self) -> M {
self.body
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn as_core(&self) -> &CoreQueueMessage {
&self.inner
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn into_core(self) -> CoreQueueMessage {
self.inner
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn complete(self, reply: M::Reply) -> Result<()> {
self.complete_raw(Some(encode_cbor(&reply, "queue message reply")?))
.await
}
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn complete_raw(self, response: Option>) -> Result<()> {
self.inner.complete(response).await
}
@@ -181,18 +230,27 @@ impl<'a, A: Actor> Queue<'a, A> {
}
/// Enqueues a message with a CBOR-encoded body.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn send(&self, name: &str, body: &T) -> Result {
self.send_raw(name, &encode_cbor(body, "queue message body")?)
.await
}
/// Enqueues a message with a raw byte body.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn send_raw(&self, name: &str, body: &[u8]) -> Result {
self.inner.send(name, body).await
}
/// Enqueues a message with a CBOR-encoded body and waits for the consumer to
/// complete it, returning the raw completion response if any.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn enqueue_and_wait(
&self,
name: &str,
@@ -204,6 +262,9 @@ impl<'a, A: Actor> Queue<'a, A> {
}
/// Enqueues a raw-body message and waits for its completion response.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn enqueue_and_wait_raw(
&self,
name: &str,
@@ -214,11 +275,17 @@ impl<'a, A: Actor> Queue<'a, A> {
}
/// Awaits the next queued message, optionally bounded by the opts timeout.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn next(&self, opts: QueueNextOpts) -> Result> {
self.inner.next(opts).await
}
/// Awaits the next queued message matching `M::NAME` and decodes its CBOR body.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn next_typed(
&self,
opts: QueueNextOpts,
@@ -231,11 +298,17 @@ impl<'a, A: Actor> Queue<'a, A> {
}
/// Awaits up to `opts.count` queued messages.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn next_batch(&self, opts: QueueNextBatchOpts) -> Result> {
self.inner.next_batch(opts).await
}
/// Awaits up to `opts.count` queued messages matching `M::NAME`.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn next_batch_typed(
&self,
opts: QueueNextBatchOpts,
@@ -249,11 +322,17 @@ impl<'a, A: Actor> Queue<'a, A> {
}
/// Returns the next queued message if one is immediately available.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn try_next(&self, opts: QueueTryNextOpts) -> Result> {
self.inner.try_next(opts)
}
/// Returns the next queued message matching `M::NAME` if one is immediately available.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn try_next_typed(
&self,
opts: QueueTryNextOpts,
@@ -265,11 +344,17 @@ impl<'a, A: Actor> Queue<'a, A> {
}
/// Returns immediately-available queued messages up to `opts.count`.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn try_next_batch(&self, opts: QueueTryNextBatchOpts) -> Result> {
self.inner.try_next_batch(opts)
}
/// Returns immediately-available queued messages matching `M::NAME`.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn try_next_batch_typed(
&self,
opts: QueueTryNextBatchOpts,
@@ -282,11 +367,17 @@ impl<'a, A: Actor> Queue<'a, A> {
}
/// Lists the currently persisted queue messages without consuming them.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub async fn inspect_messages(&self) -> Result> {
self.inner.inspect_messages().await
}
/// Returns the configured maximum queue size.
+ #[deprecated(
+ note = "Queues are deprecated. Migrate to actor streams: https://rivet.dev/actors/docs/migrate-queues-to-streams"
+ )]
pub fn max_size(&self) -> u32 {
self.inner.max_size()
}
diff --git a/rivetkit-rust/packages/rivetkit/src/start.rs b/rivetkit-rust/packages/rivetkit/src/start.rs
index b6502f25b3..0e0bbfbf8c 100644
--- a/rivetkit-rust/packages/rivetkit/src/start.rs
+++ b/rivetkit-rust/packages/rivetkit/src/start.rs
@@ -363,6 +363,37 @@ async fn handle_actor_event(
event: ActorEvent,
) -> Result {
match event {
+ ActorEvent::StreamError {
+ subscription,
+ body,
+ cancel,
+ reply,
+ } => {
+ ctx.inner().clone().register_task(async move {
+ let result = async {actor.on_stream_error(&ctx, &subscription, ciborium::from_reader(body.as_slice())?).await};
+ tokio::select! {result=result=>reply.send(result), _=cancel.cancelled()=>reply.send(Err(anyhow::anyhow!("Stream error callback canceled")))}
+ });
+ }
+
+ ActorEvent::StreamMessage {
+ subscription,
+ body,
+ cancel,
+ reply,
+ } => {
+ ctx.inner().clone().register_task(async move {
+ let result = async {
+ let body = ciborium::from_reader(body.as_slice())?;
+ actor
+ .on_subscription_message(&ctx, &subscription, body)
+ .await
+ };
+ tokio::select! {
+ result = result => reply.send(result),
+ _ = cancel.cancelled() => reply.send(Err(anyhow::anyhow!("Stream delivery canceled"))),
+ }
+ });
+ }
ActorEvent::Action {
name,
args,
diff --git a/rivetkit-rust/packages/shared-types/src/serverless_metadata.rs b/rivetkit-rust/packages/shared-types/src/serverless_metadata.rs
index a223e3892f..e96849971b 100644
--- a/rivetkit-rust/packages/shared-types/src/serverless_metadata.rs
+++ b/rivetkit-rust/packages/shared-types/src/serverless_metadata.rs
@@ -28,6 +28,9 @@ pub struct ServerlessMetadataRunner {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerlessMetadataPayload {
+ /// The actor_names map is a complete subscription manifest for this deployment.
+ #[serde(default)]
+ pub stream_subscriptions: bool,
pub runtime: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
diff --git a/rivetkit-rust/packages/shared-types/tests/serverless_metadata.rs b/rivetkit-rust/packages/shared-types/tests/serverless_metadata.rs
index 3c389949f0..765bff5c5e 100644
--- a/rivetkit-rust/packages/shared-types/tests/serverless_metadata.rs
+++ b/rivetkit-rust/packages/shared-types/tests/serverless_metadata.rs
@@ -6,6 +6,7 @@ use rivetkit_shared_types::serverless_metadata::{
#[test]
fn serverless_metadata_matches_typescript_json_shape() {
let payload = ServerlessMetadataPayload {
+ stream_subscriptions: true,
runtime: "rivetkit".to_owned(),
version: "test-version".to_owned(),
envoy_protocol_version: Some(3),
@@ -73,3 +74,15 @@ fn serverless_metadata_matches_typescript_json_shape() {
true
);
}
+
+#[test]
+fn legacy_metadata_does_not_claim_a_complete_stream_manifest() {
+ let legacy: ServerlessMetadataPayload = serde_json::from_value(
+ serde_json::json!({"runtime":"rivetkit","version":"2.3.16","actorNames":{}}),
+ )
+ .unwrap();
+ assert!(!legacy.stream_subscriptions);
+ let complete: ServerlessMetadataPayload = serde_json::from_value(serde_json::json!({"runtime":"rivetkit","version":"2.3.20","actorNames":{},"streamSubscriptions":true})).unwrap();
+ assert!(complete.stream_subscriptions);
+ assert!(complete.actor_names.is_empty());
+}
diff --git a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts
index af10f8fe0d..837c2e98c1 100644
--- a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts
+++ b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts
@@ -107,6 +107,7 @@ export interface JsActorTracingConfig {
actions?: Record
}
export interface JsActorConfig {
+ inputStream?: JsInputStreamConfig
name?: string
icon?: string
hasDatabase?: boolean
@@ -141,6 +142,20 @@ export interface JsActorConfig {
actions?: Array
inspectorTabs?: Array
}
+export interface JsInputStreamConfig {
+ deadLetters?: Record
+ primary?: boolean
+ subscriptions?: Record
+ endpoint: string
+ token?: string
+}
+export interface JsDeadLetterConfig {
+ stream?: string
+}
+export interface JsNamedStreamSubscription {
+ stream: string
+ actorKey: Array
+}
export interface JsBindParam {
kind: string
intValue?: number
@@ -354,6 +369,10 @@ export declare class ActorContext {
setAlarm(timestampMs?: number | undefined | null): void
requestSave(opts?: JsRequestSaveOpts | undefined | null): void
requestSaveAndWait(opts?: JsRequestSaveOpts | undefined | null): Promise
+ streamStatus(source: Buffer): Promise
+ streamRedrive(request: Buffer): Promise
+ streamResume(source: Buffer): Promise
+ streamAppend(body: Buffer): Promise
decodeInspectorRequest(bytes: Buffer, advertisedVersion: number): Buffer
encodeInspectorResponse(bytes: Buffer, targetVersion: number): Buffer
inspectorSnapshot(): JsInspectorSnapshot
diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs
index 765a7593c0..65bc3131a5 100644
--- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs
+++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_context.rs
@@ -438,6 +438,38 @@ impl ActorContext {
.map_err(napi_anyhow_error)
}
+ #[napi]
+ pub async fn stream_status(&self, source: Buffer) -> napi::Result {
+ self.inner()
+ .stream_status_cbor(source.as_ref())
+ .await
+ .map(Buffer::from)
+ .map_err(napi_anyhow_error)
+ }
+ #[napi]
+ pub async fn stream_redrive(&self, request: Buffer) -> napi::Result {
+ self.inner()
+ .stream_redrive_cbor(request.as_ref())
+ .await
+ .map(Buffer::from)
+ .map_err(napi_anyhow_error)
+ }
+ #[napi]
+ pub async fn stream_resume(&self, source: Buffer) -> napi::Result<()> {
+ self.inner()
+ .stream_resume_cbor(source.as_ref())
+ .await
+ .map_err(napi_anyhow_error)
+ }
+
+ #[napi]
+ pub async fn stream_append(&self, body: Buffer) -> napi::Result<()> {
+ self.inner()
+ .stream_append_cbor(body.as_ref())
+ .await
+ .map_err(napi_anyhow_error)
+ }
+
#[napi]
pub fn decode_inspector_request(
&self,
diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs
index 78b74fb5bd..4930e4ce21 100644
--- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs
+++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs
@@ -102,6 +102,7 @@ pub struct JsActorTracingConfig {
#[napi(object)]
#[derive(Clone, Default)]
pub struct JsActorConfig {
+ pub input_stream: Option,
pub name: Option,
pub icon: Option,
pub has_database: Option,
@@ -137,6 +138,29 @@ pub struct JsActorConfig {
pub inspector_tabs: Option>,
}
+#[napi(object)]
+#[derive(Clone)]
+pub struct JsInputStreamConfig {
+ pub dead_letters: Option>,
+ pub primary: Option,
+ pub subscriptions: Option>,
+ pub endpoint: String,
+ pub token: Option,
+}
+
+#[napi(object)]
+#[derive(Clone)]
+pub struct JsDeadLetterConfig {
+ pub stream: Option,
+}
+
+#[napi(object)]
+#[derive(Clone)]
+pub struct JsNamedStreamSubscription {
+ pub stream: String,
+ pub actor_key: Vec,
+}
+
#[derive(Clone)]
pub(crate) struct LifecyclePayload {
pub(crate) ctx: CoreActorContext,
@@ -257,6 +281,8 @@ pub(crate) struct AdapterConfig {
#[allow(dead_code)]
pub(crate) struct CallbackBindings {
+ pub(crate) on_stream_message: Option>,
+ pub(crate) on_stream_error: Option>,
pub(crate) create_state: Option>,
pub(crate) on_create: Option>,
pub(crate) create_conn_state: Option>,
@@ -463,6 +489,8 @@ impl CallbackBindings {
build_before_action_response_payload,
)?,
on_request: optional_tsfn(&callbacks, "onRequest", build_http_request_payload)?,
+ on_stream_message: optional_tsfn(&callbacks, "onStreamMessage", build_action_payload)?,
+ on_stream_error: optional_tsfn(&callbacks, "onStreamError", build_action_payload)?,
on_queue_send: optional_tsfn(&callbacks, "onQueueSend", build_queue_send_payload)?,
on_websocket: optional_tsfn(&callbacks, "onWebSocket", build_websocket_payload)?,
run: optional_tsfn(&callbacks, "run", build_lifecycle_payload)?,
@@ -894,6 +922,7 @@ fn build_action_payload(env: &Env, payload: ActionPayload) -> napi::Result object.set("conn", ConnHandle::new(conn))?,
None => object.set("conn", env.get_null()?)?,
}
+ object.set("name", payload.name)?;
object.set("args", Buffer::from(payload.args))?;
if let Some(fire) = payload.scheduled_fire {
let mut fire_object = env.create_object()?;
@@ -1017,6 +1046,12 @@ pub(crate) fn callback_error(callback_name: &str, error: napi::Error) -> anyhow:
impl From for ActorConfigInput {
fn from(value: JsActorConfig) -> Self {
Self {
+ input_stream: value.input_stream.map(|stream| rivetkit_core::actor::config::InputStreamConfig {
+ dead_letters:stream.dead_letters.unwrap_or_default().into_iter().map(|(name,value)|(name,rivetkit_core::actor::config::DeadLetterConfig {stream:value.stream})).collect(),
+ endpoint: stream.endpoint, token: stream.token,
+ primary: stream.primary.unwrap_or(true),
+ subscriptions: stream.subscriptions.unwrap_or_default().into_iter().map(|(name, sub)| (name, rivetkit_core::actor::stream_subscription::NamedStreamSubscription { stream: sub.stream, actor_key: sub.actor_key })).collect(),
+ }),
name: value.name,
icon: value.icon,
has_database: value.has_database,
diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs b/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs
index d841261f0d..0c69377710 100644
--- a/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs
+++ b/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs
@@ -391,6 +391,44 @@ pub(crate) async fn dispatch_event(
let _ = dirty;
match event {
+ ActorEvent::StreamError {
+ subscription,
+ body,
+ cancel,
+ reply,
+ } => {
+ let Some(callback) = bindings.on_stream_error.clone() else {
+ reply.send(Ok(()));
+ return;
+ };
+ let ctx = ctx.clone();
+ spawn_reply(tasks, abort.clone(), reply, async move {
+ tokio::select! {
+ result=call_action(&callback,&ctx,None,None,subscription,body,None,Some(cancel.clone()))=>result.map(|_|()),
+ _=cancel.cancelled()=>Err(anyhow::anyhow!("Stream error callback canceled")),
+ }
+ });
+ }
+
+ ActorEvent::StreamMessage {
+ subscription,
+ body,
+ cancel,
+ reply,
+ } => {
+ let Some(callback) = bindings.on_stream_message.clone() else {
+ reply.send(Err(missing_callback("stream.onMessage")));
+ return;
+ };
+ let ctx = ctx.clone();
+ spawn_reply(tasks, abort.clone(), reply, async move {
+ tokio::select! {
+ result = call_action(&callback, &ctx, None, None, subscription, body, None,
+ Some(cancel.clone())) => result.map(|_| ()),
+ _ = cancel.cancelled() => Err(anyhow::anyhow!("Stream delivery canceled")),
+ }
+ });
+ }
ActorEvent::Action {
name,
args,
diff --git a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs
index 57b8eebe37..aafb434bdc 100644
--- a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs
+++ b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs
@@ -234,6 +234,7 @@ pub struct WasmActorConfig {
impl From for ActorConfigInput {
fn from(config: WasmActorConfig) -> Self {
Self {
+ input_stream: None,
name: config.name,
icon: config.icon,
has_database: config.has_database,
@@ -721,6 +722,11 @@ async fn run_preamble(
async fn dispatch_event(callbacks: &WasmCallbacks, ctx: &WasmActorContext, event: ActorEvent) {
match event {
+ ActorEvent::StreamMessage { reply, .. } | ActorEvent::StreamError { reply, .. } => {
+ reply.send(Err(anyhow::anyhow!(
+ "Actor streams require the native runtime"
+ )));
+ }
ActorEvent::Action {
name,
args,
diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts
index 96d2d7af70..9d955563f4 100644
--- a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts
+++ b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts
@@ -193,6 +193,7 @@ export interface CronFire {
};
}
+/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */
export type QueueMessageOf