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 = { id: number | bigint; name: Name; @@ -201,8 +202,10 @@ export type QueueMessageOf = { [key: string]: unknown; }; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type QueueName = keyof TQueues & string; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type QueueFilterName = keyof TQueues extends never ? string : QueueName; @@ -234,6 +237,7 @@ type QueueCompletableMessageForName< TQueues extends QueueSchemaConfig, TName extends QueueFilterName, > = QueueMessageForName & { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ complete(...args: QueueCompleteArgsForName): Promise; }; @@ -246,6 +250,7 @@ type QueueCompletionResultForName< ? InferQueueCompleteMap[TName] | undefined : unknown | undefined; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type QueueResultMessageForName< TQueues extends QueueSchemaConfig, TName extends QueueFilterName, @@ -254,6 +259,7 @@ export type QueueResultMessageForName< ? QueueCompletableMessageForName : QueueMessageForName; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueNextOptions< TName extends string = string, TCompletable extends boolean = boolean, @@ -264,6 +270,7 @@ export interface QueueNextOptions< completable?: TCompletable; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueNextBatchOptions< TName extends string = string, TCompletable extends boolean = boolean, @@ -275,17 +282,20 @@ export interface QueueNextBatchOptions< completable?: TCompletable; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueWaitOptions { timeout?: number; signal?: AbortSignal; completable?: TCompletable; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueEnqueueAndWaitOptions { timeout?: number; signal?: AbortSignal; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueTryNextOptions< TName extends string = string, TCompletable extends boolean = boolean, @@ -294,6 +304,7 @@ export interface QueueTryNextOptions< completable?: TCompletable; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueTryNextBatchOptions< TName extends string = string, TCompletable extends boolean = boolean, @@ -303,6 +314,7 @@ export interface QueueTryNextBatchOptions< completable?: TCompletable; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueIterOptions< TName extends string = string, TCompletable extends boolean = boolean, @@ -312,21 +324,26 @@ export interface QueueIterOptions< completable?: TCompletable; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface ActorQueue< TQueues extends QueueSchemaConfig = Record, > { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send>( name: TName, body: QueueMessageForName["body"], ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ next< const TName extends QueueFilterName, const TCompletable extends boolean = false, >(opts?: QueueNextOptions): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ nextBatch< const TName extends QueueFilterName, const TCompletable extends boolean = false, >(opts?: QueueNextBatchOptions): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ waitForNames< const TName extends QueueFilterName, const TCompletable extends boolean = false, @@ -335,28 +352,34 @@ export interface ActorQueue< opts?: QueueWaitOptions, ): Promise; /** @experimental */ + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ waitForAvailable>( names?: readonly TName[], opts?: Omit, "completable">, ): Promise; /** @experimental */ + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ complete>( message: { id: number | bigint; name: TName }, ...args: QueueCompleteArgsForName ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ enqueueAndWait>( name: TName, body: QueueMessageForName["body"], opts?: QueueEnqueueAndWaitOptions, ): Promise>; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ tryNext< const TName extends QueueFilterName, const TCompletable extends boolean = false, >(opts?: QueueTryNextOptions): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ tryNextBatch< const TName extends QueueFilterName, const TCompletable extends boolean = false, >(opts?: QueueTryNextBatchOptions): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ iter< const TName extends QueueFilterName, const TCompletable extends boolean = false, @@ -394,6 +417,7 @@ export interface ActorContext< TDatabase extends AnyDatabaseProvider, TEvents extends EventSchemaConfig = Record, TQueues extends QueueSchemaConfig = Record, + TStreamInput = unknown, > { [ACTOR_CONTEXT_INTERNAL_SYMBOL]?: unknown; /** Returns the raw unwrapped state without the write-through proxy. */ @@ -408,7 +432,9 @@ export interface ActorContext< readonly db: InferDatabaseClient; readonly schedule: ActorSchedule; readonly cron: ActorCron; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ readonly queue: ActorQueue; + readonly stream: import("./stream").StreamWriter; /** @experimental */ readonly run: ActorRun; readonly actorId: string; @@ -1351,6 +1377,41 @@ const zActionSampleRates: z.ZodType = z.lazy(() => export const ActorConfigSchema = z .object({ + subscriptions: z + .record( + z.string().regex(/^(?!primary$)[A-Za-z0-9_-]{1,128}$/), + z + .object({ + stream: z.string().startsWith("/"), + actorKey: z.array(z.string()), + schema: z.custom().optional(), + onMessage: zFunction(), + onError: zFunction().optional(), + deadLetter: z + .union([ + z.boolean(), + z + .object({ stream: z.string().min(1) }) + .strict(), + ]) + .optional(), + }) + .strict(), + ) + .optional(), + stream: z + .object({ + schema: z.custom().optional(), + onMessage: zFunction(), + onError: zFunction().optional(), + deadLetter: z + .union([ + z.boolean(), + z.object({ stream: z.string().min(1) }).strict(), + ]) + .optional(), + }) + .optional(), onCreate: zFunction().optional(), onDestroy: zFunction().optional(), onMigrate: zFunction().optional(), @@ -1937,6 +1998,7 @@ interface BaseActorConfig< /** * Schema map for queue payloads sent by this actor. */ + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ queues?: TQueues; } @@ -1982,6 +2044,7 @@ export type ActorConfig< >, > = Omit< z.infer, + | "stream" | "actions" | "events" | "queues" @@ -2080,6 +2143,8 @@ export type ActorConfigInput< >; } & Omit< z.input, + | "stream" + | "subscriptions" | "actions" | "tracing" | "events" @@ -2305,7 +2370,7 @@ export const DocActorOptionsSchema = z .number() .optional() .describe( - "Maximum number of queue messages before rejecting new messages. Default: 1000", + "Deprecated queue option. Maximum number of queue messages before rejecting new messages. Default: 1000", ), maxSchedules: z .number() @@ -2319,7 +2384,7 @@ export const DocActorOptionsSchema = z .number() .optional() .describe( - "Maximum size of each queue message in bytes. Default: 65536", + "Deprecated queue option. Maximum size of each queue message in bytes. Default: 65536", ), canHibernateWebSocket: z .boolean() @@ -2477,7 +2542,9 @@ export const DocActorConfigSchema = z queues: z .record(z.string(), z.unknown()) .optional() - .describe("Map of queue names to schemas."), + .describe( + "Deprecated: use stream.onMessage and optional stream.schema. Map of legacy queue names to schemas.", + ), options: DocActorOptionsSchema.optional(), }) .describe("Actor configuration passed to the actor() function."); diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/definition.ts b/rivetkit-typescript/packages/rivetkit/src/actor/definition.ts index 776a620dad..593c4b7b66 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/definition.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/definition.ts @@ -1,3 +1,6 @@ +import type { z } from "zod/v4"; +import type { ActorStreamDefinition, StreamInput } from "./stream"; +import type { ActorContext } from "./config"; import type { AnyDatabaseProvider } from "@/common/database/config"; import type { RegistryConfig } from "@/registry/config"; import { @@ -98,8 +101,10 @@ export class ActorDefinition< E, Q >, + SI = unknown, > implements BaseActorDefinition { + declare readonly "~streamInput": SI; #config: ActorConfig; constructor(config: ActorConfig) { @@ -174,6 +179,9 @@ export function actor< TEvents, TQueues >, + TStreamSchema extends z.ZodType | undefined = undefined, + TStreamBody = unknown, + TSubscriptions extends Record = {}, >( input: ActorConfigInput< TState, @@ -185,7 +193,66 @@ export function actor< TEvents, TQueues, TActions - >, + > & { + subscriptions?: { + [K in keyof TSubscriptions]: { + schema?: TSubscriptions[K] & z.ZodType; + stream: string; + actorKey: string[]; + deadLetter?: boolean | { stream: string }; + onError?: NoInfer< + ( + c: ActorContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues, + StreamInput + >, + failure: import("./stream").StreamFailure, + ) => void | Promise + >; + onMessage( + c: ActorContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues, + StreamInput + >, + message: { + body: TSubscriptions[K] extends z.ZodType + ? z.output + : unknown; + }, + options: { signal: AbortSignal }, + ): void | Promise; + }; + }; + stream?: ActorStreamDefinition< + ActorContext< + TState, + TConnParams, + TConnState, + TVars, + TInput, + TDatabase, + TEvents, + TQueues, + StreamInput + >, + TStreamSchema, + TStreamBody + >; + }, ): ActorDefinition< TState, TConnParams, @@ -195,7 +262,8 @@ export function actor< TDatabase, TEvents, TQueues, - TActions + TActions, + StreamInput > { warnDeprecatedOptionKeys( (input as { options?: unknown } | undefined)?.options, diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts b/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts index 9fa7649712..9053b5274d 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/mod.ts @@ -1,4 +1,13 @@ export type { ActorKey } from "@/client/query"; +export type { + StreamMessage, + StreamWriter, + StreamSource, + StreamStatus, + StreamFailure, + DeadLetterMessage, + StreamRedriveReceipt, +} from "./stream"; export { ALLOWED_PUBLIC_HEADERS } from "@/common/actor-router-consts"; export type { Encoding } from "@/common/encoding"; export type { diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/schema.ts b/rivetkit-typescript/packages/rivetkit/src/actor/schema.ts index 63990b15a1..9bd7e58a01 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/schema.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/schema.ts @@ -11,6 +11,7 @@ export interface EventTypeToken { readonly schema?: PrimitiveSchema; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueTypeToken { readonly _queueMessage?: TMessage; readonly _queueComplete?: TComplete; @@ -39,6 +40,7 @@ export function event( return (options ?? {}) as EventTypeToken; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export function queue( options?: QueueOptions, ): QueueTypeToken { @@ -52,6 +54,7 @@ export interface EventSchemaDefinition { canSubscribe?: SchemaHook; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueSchemaDefinition { message: PrimitiveSchema; complete?: PrimitiveSchema; @@ -61,6 +64,7 @@ export interface QueueSchemaDefinition { export type EventSchema = | PrimitiveSchema | EventSchemaDefinition; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type QueueSchema = | PrimitiveSchema | QueueSchemaDefinition @@ -69,6 +73,7 @@ export type EventSchemaConfig = Record< string, EventSchema >; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type QueueSchemaConfig<_TContext = any> = Record; export type AnySchemaConfig = EventSchemaConfig | QueueSchemaConfig; @@ -94,6 +99,7 @@ export type InferSchemaMap> = { [K in keyof T]: InferSchema; }; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type InferQueueComplete = T extends QueueTypeToken ? [C] extends [never] @@ -105,6 +111,7 @@ export type InferQueueComplete = : never : never; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type InferQueueCompleteMap = { [K in keyof T]: InferQueueComplete; }; @@ -123,6 +130,7 @@ export function isStandardSchema(value: unknown): value is StandardSchemaV1 { return typeof value === "object" && value !== null && "~standard" in value; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export function isQueueSchemaDefinition( value: unknown, ): value is QueueSchemaDefinition { @@ -174,6 +182,7 @@ export function getEventCanSubscribe( : undefined; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export function getQueueCanPublish( schemas: QueueSchemaConfig | undefined, key: string, diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/stream.ts b/rivetkit-typescript/packages/rivetkit/src/actor/stream.ts new file mode 100644 index 0000000000..16d9f42647 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/src/actor/stream.ts @@ -0,0 +1,85 @@ +import type { z } from "zod/v4"; + +export interface StreamMessage { + body: T; +} + +export interface StreamSource { + /** Defaults to the actor's primary input. */ + subscription?: string; + /** Relative DS path; required for named subscriptions. */ + stream?: string; +} + +export interface StreamFailure { + subscription: string; + stream: string; + offset: string; + messageIndex: number; + attempts: number; + firstFailedAt: number; + lastFailedAt: number; + nextAttemptAt: number | null; + paused: boolean; + error: { group: string; code: string; message: string }; +} + +export interface DeadLetterMessage { + id: string; + actor: { id: string; name: string; key: unknown[] }; + subscription: string; + source: { stream: string; offset: string; messageIndex: number }; + body: T; + failure: StreamFailure; +} +export interface StreamRedriveReceipt { + failureId: string; + idempotencyKey: string; + stream: string; + nextOffset: string; + createdAt: number; +} +export interface StreamStatus { + deadLetterStream: string | null; + deadLetter: { stream: string; pending: true; error: string | null } | null; + subscription: string; + stream: string; + offset: string | null; + completed: number; + failure: StreamFailure | null; +} + +export interface StreamWriter { + /** Append failed primary input at the tail; reuse the key when retrying this operation. */ + redrive( + message: DeadLetterMessage, + options: { idempotencyKey: string }, + ): Promise; + /** Inspect durable progress and failure state for one source. */ + status(source?: StreamSource): Promise; + /** Retry the paused record with a fresh retry budget. */ + resume(source?: StreamSource): Promise; + /** Resolves after durable acceptance, before processing completes. */ + append(body: T): Promise; +} + +export type StreamInput = S extends z.ZodType ? z.input : T; +export type StreamOutput = S extends z.ZodType ? z.output : T; + +export interface ActorStreamDefinition { + schema?: S; + /** After retries, publish to a separate stream before continuing. */ + deadLetter?: boolean | { stream: string }; + /** Called after retries are exhausted. Without a DLQ, the source remains paused until resumed. */ + // Keep this callback from competing with onMessage when inferring input. + onError?: NoInfer<(c: C, failure: StreamFailure) => void | Promise>; + onMessage( + c: C, + message: StreamMessage>, + options: { signal: AbortSignal }, + ): void | Promise; +} + +export type ActorStreamInput = A extends { readonly "~streamInput": infer T } + ? T + : never; diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-common.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-common.ts index 1f5f7befed..d0284b93ad 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-common.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-common.ts @@ -92,21 +92,25 @@ export type ActorDefinitionActions = : ActorActionMap>; type ActorQueueSend = { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ ( name: K, body: InferSchemaMap[K], options: QueueSendWaitOptions, ): Promise[K]>>; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ ( name: K, body: InferSchemaMap[K], options?: QueueSendNoWaitOptions, ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ ( name: keyof TQueues extends never ? string : never, body: unknown, options: QueueSendWaitOptions, ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ ( name: keyof TQueues extends never ? string : never, body: unknown, @@ -125,13 +129,20 @@ type ActorEventSubscribe = { ): () => void; }; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type ActorDefinitionQueueSend = // biome-ignore lint/suspicious/noExplicitAny: safe to use any here IsAny extends true - ? { send: ActorQueueSend> } + ? { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ + send: ActorQueueSend>; + } : AD extends { config: { queues?: infer Q } } ? Q extends QueueSchemaConfig - ? { send: ActorQueueSend } + ? { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ + send: ActorQueueSend; + } : {} : AD extends BaseActorDefinition< any, @@ -145,7 +156,10 @@ export type ActorDefinitionQueueSend = any > ? Q extends QueueSchemaConfig - ? { send: ActorQueueSend } + ? { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ + send: ActorQueueSend; + } : {} : {}; diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts index f27021ab91..50bacdf9a5 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-conn.ts @@ -284,16 +284,19 @@ export class ActorConnRaw { ); } + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send( name: string, body: unknown, options: QueueSendWaitOptions, ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send( name: string, body: unknown, options?: QueueSendNoWaitOptions, ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send( name: string, body: unknown, diff --git a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts index 106fd2cab3..9819ae678f 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/actor-handle.ts @@ -1,5 +1,9 @@ import type { AnyActorDefinition } from "@/actor/definition"; -import { type ActorSpecifier, encodeErrorForBridge } from "@/actor/errors"; +import { + type ActorSpecifier, + encodeErrorForBridge, + RivetError, +} from "@/actor/errors"; import type { ActorInvocationSpanContext } from "@/common/actor-telemetry-context"; import { HEADER_CONN_PARAMS, @@ -81,6 +85,73 @@ import { sendHttpRequest } from "./utils"; * @see {@link ActorHandle} */ export class ActorHandleRaw { + readonly stream = { + status: async ( + source: import("@/actor/stream").StreamSource = {}, + ): Promise => { + const response = await this.fetch("/internal/streams/status", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(source), + }); + if (!response.ok) + throw new RivetError( + "stream", + "status_failed", + `Stream status failed (HTTP ${response.status})`, + ); + return response.json() as Promise< + import("@/actor/stream").StreamStatus + >; + }, + redrive: async ( + message: import("@/actor/stream").DeadLetterMessage, + options: { idempotencyKey: string }, + ): Promise => { + const response = await this.fetch("/internal/streams/redrive", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message, ...options }), + }); + if (!response.ok) + throw new RivetError( + "stream", + "redrive_failed", + `Stream redrive failed (HTTP ${response.status})`, + ); + return response.json() as Promise< + import("@/actor/stream").StreamRedriveReceipt + >; + }, + resume: async ( + source: import("@/actor/stream").StreamSource = {}, + ): Promise => { + const response = await this.fetch("/internal/streams/resume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(source), + }); + if (!response.ok) + throw new RivetError( + "stream", + "resume_failed", + `Stream resume failed (HTTP ${response.status})`, + ); + }, + append: async (body: unknown): Promise => { + const response = await this.fetch("/internal/streams/append", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) + throw new RivetError( + "stream", + "append_failed", + `Stream append failed (HTTP ${response.status})`, + ); + }, + }; #client: ClientRaw; #driver: EngineControlClient; #encoding: Encoding; @@ -134,16 +205,19 @@ export class ActorHandleRaw { return this.#params; } + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send( name: string, body: unknown, options: QueueSendWaitOptions, ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send( name: string, body: unknown, options?: QueueSendNoWaitOptions, ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send( name: string, body: unknown, @@ -1000,11 +1074,13 @@ export class ActorHandleRaw { ); } - /** Issue a token scoped to this actor. Defaults to actor gateway read access. */ - async issueToken(options: ActorIssueTokenOptions = {}): Promise { + /** Issue a token scoped to this actor. Defaults to actor actions and primary stream append. */ + async issueToken( + options: ActorIssueTokenOptions = {}, + ): Promise { const actorId = await this.resolve(); const permissions: ActorTokenPermissions = options.permissions ?? { - actor_gateway: ["read"], + actor_gateway: ["read", "append"], }; const grants: TokenGrant[] = ( Object.keys(permissions) as ActorTokenResource[] @@ -1069,8 +1145,11 @@ export class ActorHandleRaw { */ export type ActorHandle = Omit< ActorHandleRaw, - "connect" | "send" + "connect" | "send" | "stream" > & { + stream: import("@/actor/stream").StreamWriter< + import("@/actor/stream").ActorStreamInput + >; // Add typed version of ActorConn (instead of using AnyActorDefinition) connect(params?: unknown, options?: ActorConnectOptions): ActorConn; // Resolve method returns the actor ID diff --git a/rivetkit-typescript/packages/rivetkit/src/client/auth.ts b/rivetkit-typescript/packages/rivetkit/src/client/auth.ts index 4ef1b2ec30..5ab4aabe43 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/auth.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/auth.ts @@ -1,6 +1,6 @@ import type { Rivet } from "@rivetkit/engine-api-full"; -/** A namespace-scoped Engine grant. The caller must provide every grant explicitly. */ +/** A namespace-scoped Engine grant. Actor gateway append grants also authorize the primary stream append. */ export type TokenGrant = Rivet.AuthTokenGrant; /** Options for `client.auth.issueToken()`. `expiresIn` is in seconds. */ @@ -21,17 +21,48 @@ export interface IssuedToken { /** Resources that can be scoped to a single actor ID. */ export type ActorTokenResource = Extract< Rivet.AuthTokenGrant["resource"], - "actor_gateway" | "actor" | "actor_kv" + "actor_gateway" | "actor" | "actor_kv" | "actor_stream" >; export type ActorTokenPermissions = Partial< Record >; -/** Options for `actor.issueToken()`. Omit permissions for gateway read access only. */ +/** Options for `actor.issueToken()`. Defaults to actor actions and primary stream append. Explicit permissions replace these defaults. */ export type ActorIssueTokenOptions = Pick< IssueTokenOptions, "subject" | "expiresIn" > & { permissions?: ActorTokenPermissions; }; + +/** Pair actor input with primary-stream append, preserving explicit action-only grants. */ +export function pairPrimaryStreamGrants(grants: TokenGrant[]): TokenGrant[] { + const result = [...grants]; + for (const grant of grants) { + if ( + grant.resource !== "actor_gateway" || + !grant.operations.includes("append") + ) + continue; + const target = grant.target; + if ( + result.some( + (g) => + g.resource === "actor_stream" && + g.operations.includes("append") && + (g.target === target || + (typeof g.target === "object" && + typeof target === "object" && + g.target.id === target.id)), + ) + ) + continue; + result.push({ + resource: "actor_stream", + target, + operations: ["append"], + }); + } + return result; +} diff --git a/rivetkit-typescript/packages/rivetkit/src/client/lifecycle-errors.ts b/rivetkit-typescript/packages/rivetkit/src/client/lifecycle-errors.ts index 4466aa3e01..13df603938 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/lifecycle-errors.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/lifecycle-errors.ts @@ -64,6 +64,20 @@ function classifyActorError( return undefined; } + // Envoy rejects a stale generation before admitting the request, so no user + // code has run. Retry through the gateway as routing catches up. + if (error.group === "envoy" && error.code === "actor_generation_mismatch") { + return buildLifecycleBoundaryInfo( + "request_retry", + "actor_error", + error.message, + { + group: error.group, + code: error.code, + }, + ); + } + if (error.group === "actor" && error.code === "restarting") { return buildLifecycleBoundaryInfo( "request_retry", diff --git a/rivetkit-typescript/packages/rivetkit/src/client/mod.ts b/rivetkit-typescript/packages/rivetkit/src/client/mod.ts index 47f3cc8429..621ce8bdd6 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/mod.ts @@ -83,3 +83,5 @@ export function createClient>( return createClientWithDriver(driver, config); } + +export type { StreamSource, StreamStatus, StreamFailure, DeadLetterMessage, StreamRedriveReceipt } from "@/actor/stream"; diff --git a/rivetkit-typescript/packages/rivetkit/src/client/queue.ts b/rivetkit-typescript/packages/rivetkit/src/client/queue.ts index c081a78b0f..d2c9179258 100644 --- a/rivetkit-typescript/packages/rivetkit/src/client/queue.ts +++ b/rivetkit-typescript/packages/rivetkit/src/client/queue.ts @@ -19,12 +19,15 @@ import { decodeCborCompat, encodeCborCompat } from "@/serde"; import { bufferToArrayBuffer } from "@/utils"; import { sendHttpRequest } from "./utils"; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueSender { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send( name: string, body: unknown, options: QueueSendWaitOptions, ): Promise; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ send( name: string, body: unknown, @@ -32,22 +35,34 @@ export interface QueueSender { ): Promise; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueSendWaitOptions { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ wait: true; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ timeout?: number; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ signal?: AbortSignal; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueSendNoWaitOptions { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ wait?: false; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ timeout?: never; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ signal?: AbortSignal; } +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export type QueueSendOptions = QueueSendWaitOptions | QueueSendNoWaitOptions; +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export interface QueueSendResult { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ status: "completed" | "timedOut"; + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ response?: TResponse; } @@ -58,7 +73,7 @@ interface QueueSenderOptions { telemetryHeaders: () => Record; customFetch: (request: Request) => Promise; } - +/** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ export function createQueueSender( senderOptions: QueueSenderOptions, ): QueueSender { @@ -85,7 +100,13 @@ export function createQueueSender( protocol.HttpQueueSendResponse, HttpQueueSendRequestJson, HttpQueueSendResponseJson, - { body: unknown; wait?: boolean; timeout?: number; name?: string }, + { + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */ + body: unknown /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */; + wait?: boolean /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */; + timeout?: number /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. */; + name?: string; + }, QueueSendResult >({ url: `http://actor/queue/${encodeURIComponent(name)}`, diff --git a/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts index 21f6d6eedb..6945bbf885 100644 --- a/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts @@ -1,3 +1,4 @@ +import { pairPrimaryStreamGrants } from "@/client/auth"; import { serializeActorKey } from "@/actor/keys"; import type { ClientConfig } from "@/client/client"; import type { IssuedToken, IssueTokenOptions } from "@/client/auth"; @@ -36,7 +37,7 @@ export async function issueToken( namespace: config.namespace, subject: options.subject, duration: options.expiresIn, - grants: options.grants, + grants: pairPrimaryStreamGrants(options.grants), }); } diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts b/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts index 63d2b43a1c..8d51737bb8 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/config/index.ts @@ -255,6 +255,13 @@ export const RegistryConfigSchema = z * RIVET_RUN_SERVICES=0 or 1 to override it. */ startServices: z.boolean().optional(), + /** Full Durable Streams root and its management credential. */ + durableStreams: z + .object({ + endpoint: z.url(), + token: z.string().optional(), + }) + .optional(), /** * @experimental * diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts index f822e70a0e..93edd0d187 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts @@ -264,6 +264,31 @@ function toNapiQueueMessage(message: NapiQueueMessage): RuntimeQueueMessage { } export class NapiCoreRuntime implements CoreRuntime { + async actorStreamStatus( + ctx: ActorContextHandle, + source: RuntimeBytes, + ): Promise { + return asNativeActorContext(ctx).streamStatus(Buffer.from(source)); + } + async actorStreamRedrive( + ctx: ActorContextHandle, + request: RuntimeBytes, + ): Promise { + return asNativeActorContext(ctx).streamRedrive(Buffer.from(request)); + } + async actorStreamResume( + ctx: ActorContextHandle, + source: RuntimeBytes, + ): Promise { + await asNativeActorContext(ctx).streamResume(Buffer.from(source)); + } + + async actorStreamAppend( + ctx: ActorContextHandle, + body: RuntimeBytes, + ): Promise { + await asNativeActorContext(ctx).streamAppend(Buffer.from(body)); + } readonly kind = "napi"; #bindings: NativeBindings; diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index 0c5c55648e..874d4b662e 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -2728,6 +2728,73 @@ class NativeConnectionMap implements ReadonlyMap { const NO_INVOCATION_SCOPE = {}; export class ActorContextHandleAdapter { + get stream() { + return { + status: async ( + source: import("@/actor/stream").StreamSource = {}, + ): Promise => { + if (!this.#runtime.actorStreamStatus) + throw new RivetError( + "stream", + "not_configured", + "Actor streams require the native runtime.", + ); + return decodeCborCompat( + await this.#runtime.actorStreamStatus( + this.#ctx, + encodeCborCompat(source as JsonCompatValue), + ), + ) as unknown as import("@/actor/stream").StreamStatus; + }, + redrive: async ( + message: import("@/actor/stream").DeadLetterMessage, + options: { idempotencyKey: string }, + ): Promise => { + if (!this.#runtime.actorStreamRedrive) + throw new RivetError( + "stream", + "not_configured", + "Actor streams require the native runtime.", + ); + return decodeCborCompat( + await this.#runtime.actorStreamRedrive( + this.#ctx, + encodeCborCompat({ + message, + ...options, + } as unknown as JsonCompatValue), + ), + ) as unknown as import("@/actor/stream").StreamRedriveReceipt; + }, + resume: async ( + source: import("@/actor/stream").StreamSource = {}, + ): Promise => { + if (!this.#runtime.actorStreamResume) + throw new RivetError( + "stream", + "not_configured", + "Actor streams require the native runtime.", + ); + await this.#runtime.actorStreamResume( + this.#ctx, + encodeCborCompat(source as JsonCompatValue), + ); + }, + append: async (body: unknown): Promise => { + if (!this.#runtime.actorStreamAppend) + throw new RivetError( + "stream", + "not_configured", + "Actor streams require the native runtime.", + ); + assertJsonCompatValue(body); + await this.#runtime.actorStreamAppend( + this.#ctx, + encodeCborCompat(body as JsonCompatValue), + ); + }, + }; + } #runtime: CoreRuntime; #ctx: ActorContextHandle; #schemas: NativeValidationConfig; @@ -3902,6 +3969,16 @@ function buildActorConfig( runtimeKind: "napi" | "wasm", ): RuntimeActorConfig { const config = definition.config as unknown as Record; + if ( + (config.stream || config.subscriptions) && + (!registryConfig.durableStreams || runtimeKind !== "napi") + ) { + throw new RivetError( + "stream", + "not_configured", + "Actor streams require the native runtime and setup({ durableStreams: { endpoint } }).", + ); + } const options = (config.options ?? {}) as Record; const canHibernate = options.canHibernateWebSocket; const usesRemoteSqlite = @@ -3912,6 +3989,43 @@ function buildActorConfig( return { name: options.name as string | undefined, + inputStream: + config.stream || config.subscriptions + ? { + ...registryConfig.durableStreams!, + primary: !!config.stream, + deadLetters: Object.fromEntries( + Object.entries({ + ...(config.subscriptions ?? {}), + ...(config.stream + ? { primary: config.stream } + : {}), + }).flatMap(([name, sub]) => { + const option = ( + sub as { + deadLetter?: + | boolean + | { stream: string }; + } + ).deadLetter; + return option + ? [[name, option === true ? {} : option]] + : []; + }), + ), + subscriptions: Object.fromEntries( + Object.entries( + (config.subscriptions ?? {}) as Record< + string, + { stream: string; actorKey: string[] } + >, + ).map(([name, sub]) => [ + name, + { stream: sub.stream, actorKey: sub.actorKey }, + ]), + ), + } + : undefined, icon: options.icon as string | undefined, hasDatabase: true, remoteSqlite: usesRemoteSqlite, @@ -4627,6 +4741,87 @@ export function buildNativeFactory( } }; const callbacks = { + onStreamError: + config.stream || config.subscriptions + ? wrapNativeCallback( + async ( + error: unknown, + payload: { + ctx: ActorContextHandle; + name: string; + args: RuntimeBytes; + cancelToken?: CancellationTokenHandle; + }, + ) => { + const { ctx, name, args, cancelToken } = + unwrapTsfnPayload(error, payload); + const actorCtx = makeActorCtx( + ctx, + undefined, + cancelToken, + ); + try { + const handler = + name === "primary" + ? config.stream + : config.subscriptions?.[name]; + await handler?.onError?.( + actorCtx, + decodeCborCompat(args), + ); + return encodeValue(null); + } finally { + await actorCtx.dispose(); + } + }, + ) + : undefined, + onStreamMessage: + config.stream || config.subscriptions + ? wrapNativeCallback( + async ( + error: unknown, + payload: { + ctx: ActorContextHandle; + name: string; + args: RuntimeBytes; + cancelToken?: CancellationTokenHandle; + }, + ) => { + const { ctx, args, name, cancelToken } = + unwrapTsfnPayload(error, payload); + const actorCtx = makeActorCtx( + ctx, + undefined, + cancelToken, + ); + try { + const handler = + name === "primary" + ? config.stream + : config.subscriptions?.[name]; + if (!handler) + throw new RivetError( + "stream", + "not_configured", + "Stream subscription handler is not configured.", + ); + const value = decodeCborCompat(args); + const body = handler.schema + ? await handler.schema.parseAsync(value) + : value; + await handler.onMessage( + actorCtx, + { body }, + { signal: actorCtx.abortSignal }, + ); + return encodeValue(null); + } finally { + await actorCtx.dispose(); + } + }, + ) + : undefined, createState: hasStaticState || typeof config.createState === "function" ? wrapNativeCallback( diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts index 286868ff45..85b9d63cdd 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts @@ -326,6 +326,13 @@ export interface RuntimeActorTracingConfig { } export interface RuntimeActorConfig { + inputStream?: { + deadLetters?: Record; + endpoint: string; + token?: string; + primary?: boolean; + subscriptions?: Record; + }; name?: string; icon?: string; hasDatabase?: boolean; @@ -480,6 +487,22 @@ export type RuntimeWebSocketEvent = }; export interface CoreRuntime { + actorStreamStatus?( + ctx: ActorContextHandle, + source: RuntimeBytes, + ): Promise; + actorStreamRedrive?( + ctx: ActorContextHandle, + request: RuntimeBytes, + ): Promise; + actorStreamResume?( + ctx: ActorContextHandle, + source: RuntimeBytes, + ): Promise; + actorStreamAppend?( + ctx: ActorContextHandle, + body: RuntimeBytes, + ): Promise; readonly kind: "napi" | "wasm"; createRegistry(): RegistryHandle; diff --git a/rivetkit-typescript/packages/rivetkit/src/workflow/context.ts b/rivetkit-typescript/packages/rivetkit/src/workflow/context.ts index a4323434af..8f1bef2bc4 100644 --- a/rivetkit-typescript/packages/rivetkit/src/workflow/context.ts +++ b/rivetkit-typescript/packages/rivetkit/src/workflow/context.ts @@ -399,6 +399,7 @@ export class WorkflowStepContext< ); } + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. Workflow stream integration is deferred; existing workflow queues remain supported. */ get queue() { const self = this; function send( @@ -510,6 +511,7 @@ export class WorkflowContext< return this.#runCtx.log; } + /** @deprecated Queues are deprecated. Use actor streams. See https://rivet.dev/actors/docs/migrate-queues-to-streams. Workflow stream integration is deferred; existing workflow queues remain supported. */ get queue() { const self = this; function next< diff --git a/rivetkit-typescript/packages/rivetkit/tests/actor-streams.integration.test.ts b/rivetkit-typescript/packages/rivetkit/tests/actor-streams.integration.test.ts new file mode 100644 index 0000000000..7c46391368 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/actor-streams.integration.test.ts @@ -0,0 +1,831 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import getPort from "get-port"; +import { afterAll, beforeAll, expect, test } from "vitest"; +import { createClient } from "../src/client/mod"; +import type { Registry } from "./fixtures/actor-streams-server"; +import { + getOrStartSharedTestEngine, + releaseSharedTestEngine, + TEST_ENGINE_TOKEN, +} from "./shared-engine"; + +const children: ChildProcess[] = []; +let endpoint: string; +let root: string; +let token: string; +let management: string; +let pool: string; +let startActorHost: (version: number) => ChildProcess; +let actorHost: ChildProcess; +let servicesHost: ChildProcess; +let startServicesHost: () => ChildProcess; + +async function dsFetch(url: string, init: RequestInit = {}) { + const headers = new Headers(init.headers); + if (!headers.has("authorization")) + headers.set("authorization", `Bearer ${token}`); + return fetch(url, { ...init, headers }); +} + +async function until(check: () => Promise, timeout = 30_000) { + const end = Date.now() + timeout; + while (Date.now() < end) { + if (await check()) return; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error("Actor Streams condition timed out"); +} + +async function api( + path: string, + body?: unknown, + credential = TEST_ENGINE_TOKEN, +): Promise { + const response = await fetch(endpoint + path, { + method: body === undefined ? "GET" : "POST", + headers: { + authorization: `Bearer ${credential}`, + "content-type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + expect(response.ok, `Engine request: ${response.status}`).toBe(true); + return response.json(); +} + +async function subscription(actorId: string, name = "primary") { + const response = await dsFetch( + `${root}/__ds/subscriptions/actor-${actorId}${name === "primary" ? "" : `-${name}`}`, + { + headers: { authorization: `Bearer ${management}` }, + }, + ); + if (response.status === 404 || response.status === 410) return undefined; + expect( + response.ok, + `Subscription response: ${response.status} ${response.ok ? "" : await response.text()}`, + ).toBe(true); + return response.json(); +} + +async function acknowledged(actorId: string) { + await until(async () => { + const sub = await subscription(actorId); + if (!sub || sub.streams.length !== 1) return false; + const stream = await dsFetch(`${root}/${sub.streams[0].path}`, { + method: "HEAD", + }); + expect(stream.ok).toBe(true); + return ( + sub.streams[0].acked_offset === + stream.headers.get("stream-next-offset") + ); + }, 90_000); +} + +async function asleep(actorId: string) { + await until(async () => { + const result = await api( + `/actors?namespace=default&actor_ids=${encodeURIComponent(actorId)}`, + ); + return !!result.actors[0]?.sleep_ts; + }); +} + +beforeAll(async () => { + const engineBinaryPath = process.env.RIVET_STREAMS_TEST_ENGINE_BINARY; + const servicesBinary = process.env.RIVET_STREAMS_TEST_SERVICES_BINARY; + if (!engineBinaryPath || !servicesBinary) + throw new Error( + "Set RIVET_STREAMS_TEST_ENGINE_BINARY (signing-capable) and RIVET_STREAMS_TEST_SERVICES_BINARY (DS subscriptions + gateway auth).", + ); + ({ endpoint } = await getOrStartSharedTestEngine({ + gateway3: true, + engineBinaryPath, + deterministicScheduling: true, + })); + await until( + async () => + ( + await fetch(`${endpoint}/namespaces?name=default`, { + headers: { authorization: `Bearer ${TEST_ENGINE_TOKEN}` }, + }) + ).ok, + ); + const namespace = (await api("/namespaces?name=default")).namespaces[0] + .namespace_id; + const role = `actor-streams-${randomUUID()}`; + const rules = Object.fromEntries( + [ + ["application_token", ["create"], { id: namespace }], + ["webhook_signing", ["create"], { id: namespace }], + ["namespace", ["read"], "any"], + ["datacenter", ["read", "list"], "any"], + ...[ + "actor", + "runner", + "runner_config", + "actor_gateway", + "actor_kv", + "actor_stream", + "durable_stream", + "jwt", + ].map((resource) => [ + resource, + [ + "read", + "list", + "create", + "update", + "delete", + "append", + "close", + ], + { id: namespace }, + ]), + ].map(([resource, operations, ns]) => [ + `${role}-${resource}`, + { namespace: ns, resource, target: "any", operations }, + ]), + ); + await api("/acl/manifest", { + rules, + policies: { [role]: { rules: Object.keys(rules) } }, + roles: { [role]: { policies: [role] } }, + }); + token = (await api("/acl/tokens", { roles: [role], duration: 3600 })).token; + management = randomUUID(); + pool = `streams-${randomUUID()}`; + const port = await getPort(); + root = `http://127.0.0.1:${port}/durable-streams/v1/stream`; + const logs = await mkdtemp(join(tmpdir(), "rivetkit-actor-streams-")); + console.log(`Actor Streams logs: ${logs}`); + const env = { + ...process.env, + RIVET_ENDPOINT: endpoint, + RIVET_NAMESPACE: "default", + RIVET_TOKEN: token, + RIVET_ENGINE_BINARY_PATH: engineBinaryPath, + RIVET_ENGINE_BINARY: engineBinaryPath, + RIVET_RUN_SERVICES: "0", + RIVETKIT_ENGINE_SPAWN: "never", + RUST_LOG: "warn", + RIVET_LOG_LEVEL: "warn", + DS_SUBSCRIPTIONS_MANAGEMENT_TOKEN: management, + DS_SUBSCRIPTIONS_ALLOW_LOCAL_HTTP: "1", + DS_ACTOR_ROUTING: "1", + PUBLIC_ORIGIN: `http://127.0.0.1:${port}`, + HOST: "0.0.0.0", + PORT: String(port), + RIVET_STREAMS_TEST_ROOT: root, + RIVET_STREAMS_TEST_POOL: pool, + }; + function start( + command: string, + args: string[], + name: string, + extraEnv: Record = {}, + ) { + const child = spawn(command, args, { + env: { ...env, ...extraEnv }, + stdio: ["ignore", "pipe", "pipe"], + }); + const log = createWriteStream(join(logs, name)); + child.stdout!.pipe(log, { end: false }); + child.stderr!.pipe(log, { end: false }); + child.once("close", () => log.end()); + children.push(child); + return child; + } + startServicesHost = () => + start(servicesBinary, ["start"], `services-${Date.now()}.log`); + servicesHost = startServicesHost(); + startActorHost = (version) => + start( + process.execPath, + ["--import", "tsx", "tests/fixtures/actor-streams-server.ts"], + `actors-v${version}.log`, + { RIVET_STREAMS_TEST_VERSION: String(version) }, + ); + actorHost = startActorHost(1); + await until(async () => { + for (const child of children) + if (child.exitCode !== null) + throw new Error(`Host exited; inspect ${logs}`); + const result = await api("/envoys?namespace=default"); + return ( + result.envoys.some((e: any) => e.pool_name === pool) && + result.envoys.some((e: any) => e.pool_name === "services") + ); + }, 60_000); +}, 120_000); + +afterAll(async () => { + for (const child of children.reverse()) { + child.kill("SIGTERM"); + await Promise.race([ + new Promise((r) => child.once("exit", () => r())), + new Promise((r) => + setTimeout(() => { + child.kill("SIGKILL"); + r(); + }, 3000), + ), + ]); + } + await releaseSharedTestEngine(); +}); + +test("native onMessage: acceptance, signed gateway wake from sleep, ordered progress across restarts, and schema inference", async () => { + const client = createClient({ + endpoint, + namespace: "default", + token, + poolName: pool, + }); + const worker = client.worker.getOrCreate([randomUUID()]); + await worker.stream.append({ id: 1 }); + const actorId = await worker.resolve(); + await acknowledged(actorId); + expect(await worker.snapshot()).toEqual([1]); + await asleep(actorId); + + // Append directly to DS: only its webhook can wake the sleeping application actor. + const response = await dsFetch(`${root}/actors/worker/${actorId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify([{ id: 2 }, { id: 3, delay: 12_000 }]), + }); + expect(response.ok).toBe(true); + const tail = response.headers.get("stream-next-offset"); + await until( + async () => + (await subscription(actorId)).streams[0].acked_offset === tail, + 60_000, + ); + expect(await worker.snapshot()).toEqual([1, 2, 3]); + await asleep(actorId); + await worker.stream.append({ id: 4 }); + await acknowledged(actorId); + expect(await worker.snapshot()).toEqual([1, 2, 3, 4]); + await worker.appendSelf(5); + await acknowledged(actorId); + expect(await worker.snapshot()).toEqual([1, 2, 3, 4, 5]); + + const invalid = await fetch( + `${endpoint}/gateway/${actorId}/internal/streams/wake`, + { + method: "POST", + headers: { + "x-rivet-token": token, + "content-type": "application/json", + }, + body: "{}", + }, + ); + expect(invalid.status).toBe(401); + const unauthorized = await fetch( + `${endpoint}/gateway/${actorId}/internal/streams/wake`, + { method: "POST", body: "{}" }, + ); + expect(unauthorized.ok).toBe(false); + + const validated = client.validated.getOrCreate([randomUUID()]); + await validated.stream.append({ id: "6" }); + await acknowledged(await validated.resolve()); + expect(await validated.snapshot()).toEqual([6]); +}, 180_000); + +test("handler retries persist across restart, pause after five retries, and resume the blocked record", async () => { + const client = createClient({ + endpoint, + namespace: "default", + token, + poolName: pool, + }); + const worker = client.recovering.getOrCreate([randomUUID()]); + await worker.stream.append({ id: 0 }); + const actorId = await worker.resolve(); + await acknowledged(actorId); + const previous = (await subscription(actorId)).streams[0].acked_offset; + await asleep(actorId); + const response = await dsFetch(`${root}/actors/recovering/${actorId}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify([{ id: 7 }, { id: 8 }, { id: 9 }]), + }); + expect(response.ok).toBe(true); + + await until(async () => !!(await worker.stream.status()).failure); + expect((await worker.snapshot()).messages).toEqual([0, 7]); + const firstFailure = (await worker.stream.status()).failure!; + expect(firstFailure.attempts).toBeLessThan(6); + const old = actorHost; + actorHost = startActorHost(1); + await new Promise((r) => setTimeout(r, 1_000)); + old.kill("SIGTERM"); + await until(async () => { + try { + return (await worker.stream.status()).failure?.paused === true; + } catch (error) { + if (error instanceof Error && error.message.includes("HTTP 503")) + return false; + throw error; + } + }, 120_000); + const paused = await worker.stream.status(); + expect(paused.failure?.attempts).toBe(6); + expect(paused.failure?.firstFailedAt).toBe(firstFailure.firstFailedAt); + expect((await subscription(actorId)).streams[0].acked_offset).toBe( + previous, + ); + await until(async () => (await worker.snapshot()).errors === 1); + await asleep(actorId); + expect((await worker.stream.status()).failure?.paused).toBe(true); + const inputToken = await worker.issueToken(); + const denied = await fetch( + `${endpoint}/gateway/${actorId}/internal/streams/resume`, + { + method: "POST", + headers: { + "x-rivet-token": inputToken.token, + "content-type": "application/json", + }, + body: "{}", + }, + ); + expect(denied.status).toBe(403); + await worker.unblock(); + await worker.stream.resume(); + await acknowledged(actorId); + expect((await worker.snapshot()).messages).toEqual([0, 7, 8, 9]); + expect((await worker.stream.status()).failure).toBeNull(); +}, 240_000); + +async function writeSource(path: string, id: string, method = "PUT") { + const response = await dsFetch(`${root}/${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify([{ id }]), + }); + expect( + response.ok, + `Stream write: ${response.status} ${response.ok ? "" : await response.text()}`, + ).toBe(true); + return response.headers.get("stream-next-offset"); +} +async function namedAcknowledged( + actorId: string, + name: string, + path: string, + tail: string | null, +) { + await until( + async () => + (await subscription(actorId, name))?.streams.some( + (s: any) => s.path === path && s.acked_offset === tail, + ), + 90_000, + ); +} +async function deploy(version: number) { + const previous = actorHost; + actorHost = startActorHost(version); + await until(async () => { + const names = await api("/actors/names?namespace=default"); + return ( + names.names.customer?.metadata.streamSubscriptionSources?.[pool] + ?.version === version + ); + }, 60_000); + previous.kill("SIGTERM"); +} + +test("pausing one named source does not block another source for the same actor", async () => { + const tenant = randomUUID(), + key = randomUUID(); + const bad = `tenants/${tenant}/orders/${key}/bad`; + const good = `tenants/${tenant}/orders/${key}/good`; + await writeSource(bad, "poison"); + const client = createClient({ + endpoint, + namespace: "default", + token, + poolName: pool, + }); + const customer = client.customer.getOrCreate([tenant, key]); + const id = await customer.resolve(); + await until( + async () => + ( + await customer.stream.status({ + subscription: "orders", + stream: bad, + }) + ).failure?.paused === true, + 60_000, + ); + const tail = await writeSource(good, "healthy"); + await namedAcknowledged(id, "orders", good, tail); + expect(await customer.snapshot()).toEqual(["orders:healthy"]); + expect( + (await customer.stream.status({ subscription: "orders", stream: bad })) + .failure?.attempts, + ).toBe(6); +}, 150_000); + +test("one paired token authorizes actor and direct DS append, but rejects other stream operations and invalid credentials", async () => { + const admin = createClient({ + endpoint, + namespace: "default", + token, + poolName: pool, + }); + const owner = admin.worker.getOrCreate([randomUUID()]); + const actorId = await owner.resolve(); + const paired = await owner.issueToken({ + permissions: { actor_gateway: ["append"] }, + }); + const client = createClient({ + endpoint, + namespace: "default", + token: paired.token, + poolName: pool, + disableMetadataLookup: true, + }); + await client.worker.getForId(actorId).stream.append({ id: 1 }); + const url = `${root}/actors/worker/${actorId}`; + const headers = { + authorization: `Bearer ${paired.token}`, + "content-type": "application/json", + }; + expect( + ( + await fetch(url, { + method: "POST", + headers, + body: JSON.stringify([{ id: 2 }]), + }) + ).ok, + ).toBe(true); + await acknowledged(actorId); + for (const method of ["GET", "HEAD", "DELETE"]) { + expect((await fetch(url, { method, headers })).status).toBe(403); + } + expect( + ( + await fetch(url, { + method: "POST", + headers: { ...headers, "stream-closed": "true" }, + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(url, { + method: "POST", + headers: { ...headers, "stream-closed": "TRUE" }, + }) + ).status, + ).toBe(403); + expect((await fetch(url, { method: "POST", body: "[]" })).status).toBe(401); + expect( + ( + await fetch(url, { + method: "POST", + headers: { authorization: "Bearer invalid" }, + body: "[]", + }) + ).status, + ).toBe(401); + const actionOnly = await owner.issueToken({ + permissions: { actor_gateway: ["read"] }, + }); + expect( + ( + await fetch(url, { + method: "POST", + headers: { + ...headers, + authorization: `Bearer ${actionOnly.token}`, + }, + body: "[]", + }) + ).status, + ).toBe(403); + const otherNamespace = `streams-${randomUUID()}`; + await api("/namespaces", { + name: otherNamespace, + display_name: "Stream auth test", + }); + const wrong = await api("/auth/tokens", { + namespace: otherNamespace, + grants: [ + { + resource: "actor_stream", + target: { id: actorId }, + operations: ["append"], + }, + ], + }); + expect( + ( + await fetch(url, { + method: "POST", + headers: { ...headers, authorization: `Bearer ${wrong.token}` }, + body: "[]", + }) + ).status, + ).toBe(403); + const short = await owner.issueToken({ + expiresIn: 1, + permissions: { actor_gateway: ["append"] }, + }); + await until( + async () => + ( + await fetch(url, { + method: "POST", + headers: { + ...headers, + authorization: `Bearer ${short.token}`, + }, + body: "[]", + }) + ).status === 401, + 45_000, + ); +}, 90_000); + +test("destroyed keyed destinations are replaced and destroyed primary IDs are not recreated", async () => { + const tenant = randomUUID(), + key = randomUUID(); + const path = `tenants/${tenant}/orders/${key}/replacement`; + const tail = await writeSource(path, "before"); + const lookup = async () => + ( + await api( + `/actors?namespace=default&name=customer&key=${encodeURIComponent(`${tenant}/${key}`)}`, + ) + ).actors[0]?.actor_id; + let id = ""; + await until(async () => !!(id = await lookup())); + await namedAcknowledged(id, "orders", path, tail); + const admin = createClient({ + endpoint, + namespace: "default", + token, + poolName: pool, + }); + const old = admin.customer.getForId(id); + await old.stream.append({ id: "primary-before" }); + await acknowledged(id); + const destroyed = await fetch( + `${endpoint}/actors/${id}?namespace=default`, + { method: "DELETE", headers: { authorization: `Bearer ${token}` } }, + ); + expect(destroyed.ok).toBe(true); + const next = await writeSource(path, "after", "POST"); + let replacement = ""; + await until( + async () => !!(replacement = await lookup()) && replacement !== id, + 60_000, + ); + await namedAcknowledged(replacement, "orders", path, next); + expect(await admin.customer.getForId(replacement).snapshot()).toEqual([ + "orders:after", + ]); + await writeSource(`actors/customer/${id}`, "primary-after", "POST"); + await until(async () => !(await subscription(id)), 60_000); + expect(await lookup()).toBe(replacement); +}, 120_000); + +test("named subscriptions route first writes, multiple sources, deployment additions and removals without waking actors to register", async () => { + const client = createClient({ + endpoint, + namespace: "default", + token, + poolName: pool, + }); + const tenant = randomUUID(), + key = randomUUID(); + const first = `tenants/${tenant}/orders/${key}/one`; + // No application actor exists yet. DS resolves the key and includes PUT's body. + const firstTail = await writeSource(first, "first"); + let id = ""; + await until(async () => { + const result = await api( + `/actors?namespace=default&name=customer&key=${encodeURIComponent(`${tenant}/${key}`)}`, + ); + id = result.actors[0]?.actor_id ?? ""; + return !!id; + }); + const customer = client.customer.getForId(id); + await namedAcknowledged(id, "orders", first, firstTail); + expect(await customer.snapshot()).toEqual(["orders:first"]); + await asleep(id); + // A stream name cannot impersonate another actor type. Malformed ID paths + // must also leave unrelated routing jobs able to make progress. + await writeSource(`actors/worker/${id}`, "wrong-type"); + await writeSource("actors/customer/not-an-actor-id", "invalid-id"); + const second = `tenants/${tenant}/orders/${key}/two`; + await namedAcknowledged( + id, + "orders", + second, + await writeSource(second, "second"), + ); + expect(await customer.snapshot()).toEqual([ + "orders:first", + "orders:second", + ]); + expect(await subscription(id)).toBeUndefined(); + await customer.stream.append({ id: "input" }); + await acknowledged(id); + await asleep(id); + servicesHost.kill("SIGTERM"); + await Promise.race([ + new Promise((resolve) => + servicesHost.once("exit", () => resolve()), + ), + new Promise((resolve) => setTimeout(resolve, 3_000)), + ]); + if (servicesHost.exitCode === null) servicesHost.kill("SIGKILL"); + servicesHost = startServicesHost(); + await until(async () => { + try { + return (await dsFetch(`${root}/__ds/jwks.json`)).ok; + } catch { + return false; + } + }, 60_000); + await namedAcknowledged( + id, + "orders", + first, + await writeSource(first, "after-restart", "POST"), + ); + expect(await customer.snapshot()).toEqual([ + "orders:first", + "orders:second", + "primary:input", + "orders:after-restart", + ]); + await asleep(id); + + const added = `tenants/${tenant}/notifications/${key}`; + await writeSource(added, "historical"); + await deploy(2); + // DS adopts metadata on its polling interval; activation is unnecessary. + await new Promise((r) => setTimeout(r, 6_000)); + await namedAcknowledged( + id, + "notifications", + added, + await writeSource(added, "new", "POST"), + ); + expect(await customer.snapshot()).toEqual([ + "orders:first", + "orders:second", + "primary:input", + "orders:after-restart", + "notifications:new", + ]); + await asleep(id); + + // A stale host reconnect must not roll the authoritative index back. + const stale = startActorHost(1); + await new Promise((r) => setTimeout(r, 2_000)); + const names = await api("/actors/names?namespace=default"); + expect( + names.names.customer.metadata.streamSubscriptionSources[pool].version, + ).toBe(2); + stale.kill("SIGTERM"); + + await deploy(3); + await until( + async () => (await subscription(id, "orders"))?.streams.length === 0, + 30_000, + ); + await writeSource(first, "removed", "POST"); + await namedAcknowledged( + id, + "notifications", + added, + await writeSource(added, "after-removal", "POST"), + ); + expect(await customer.snapshot()).toEqual([ + "orders:first", + "orders:second", + "primary:input", + "orders:after-restart", + "notifications:new", + "notifications:after-removal", + ]); + await asleep(id); + await deploy(4); + await until( + async () => + (await subscription(id, "notifications"))?.streams.length === 0, + 30_000, + ); + const removed = await api("/actors/names?namespace=default"); + expect( + removed.names.customer.metadata.streamSubscriptionSources[pool] + .subscriptions, + ).toEqual({}); +}, 240_000); + +test("dead letters continue after durable handoff and redrive once at the primary tail", async () => { + const client = createClient({ + endpoint, + namespace: "default", + token, + poolName: pool, + }); + const worker = client.deadLetters.getOrCreate([`dlq-${randomUUID()}`]); + const actorId = await worker.resolve(); + await worker.stream.append({ id: 7 }); + await worker.stream.append({ id: 8 }); + await worker.stream.append({ id: 9 }); + await until( + async () => (await worker.snapshot()).messages.join(",") === "7,9", + 90_000, + ); + await acknowledged(actorId); + const status = await worker.stream.status(); + expect(status.failure).toBeNull(); + expect(status.deadLetter).toBeNull(); + expect((await worker.snapshot()).errors).toBe(1); + const response = await dsFetch( + `${root}/${status.deadLetterStream}?offset=-1`, + ); + expect(response.ok).toBe(true); + const letters = await response.json(); + expect(letters).toHaveLength(1); + const letter = letters[0]; + expect(letter.body).toEqual({ id: 8 }); + expect(letter.failure.attempts).toBe(6); + await worker.unblock(); + const paired = await worker.issueToken(); + const redrive = () => + fetch(`${endpoint}/gateway/${actorId}/internal/streams/redrive`, { + method: "POST", + headers: { + "x-rivet-token": paired.token, + "content-type": "application/json", + }, + body: JSON.stringify({ + message: letter, + idempotencyKey: "repair-1", + }), + }); + let first: Response; + await until(async () => { + first = await redrive(); + return first.status !== 409; + }); + expect(first!.ok).toBe(true); + const receipt = await first!.json(); + await until( + async () => (await worker.snapshot()).messages.join(",") === "7,9,8", + ); + await acknowledged(actorId); + const repeat = await redrive(); + expect(repeat.ok).toBe(true); + expect(await repeat.json()).toEqual(receipt); + expect((await worker.snapshot()).messages).toEqual([7, 9, 8]); + const actionOnly = await worker.issueToken({ + permissions: { actor_gateway: ["read"] }, + }); + const denied = await fetch( + `${endpoint}/gateway/${actorId}/internal/streams/redrive`, + { + method: "POST", + headers: { + "x-rivet-token": actionOnly.token, + "content-type": "application/json", + }, + body: JSON.stringify({ + message: letter, + idempotencyKey: "repair-2", + }), + }, + ); + expect(denied.status).toBe(403); + const other = { ...letter, subscription: "orders" }; + await expect( + worker.stream.redrive(other, { idempotencyKey: "shared" }), + ).rejects.toThrow(); + await expect( + worker.stream.redrive( + { ...letter, body: { id: 10 } }, + { idempotencyKey: "repair-1" }, + ), + ).rejects.toThrow(); +}, 180_000); diff --git a/rivetkit-typescript/packages/rivetkit/tests/actor-streams.types.ts b/rivetkit-typescript/packages/rivetkit/tests/actor-streams.types.ts new file mode 100644 index 0000000000..a03c86d1ce --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/actor-streams.types.ts @@ -0,0 +1,119 @@ +import { expectTypeOf } from "vitest"; +import { z } from "zod/v4"; +import { actor } from "../src/actor/definition"; +import type { ActorStreamInput } from "../src/actor/stream"; +import type { ActorHandle } from "../src/client/actor-handle"; + +const typed = actor({ + state: { count: 0 }, + stream: { + onError(c, failure) { + expectTypeOf(c.state.count).toEqualTypeOf(); + expectTypeOf(failure.attempts).toEqualTypeOf(); + }, + async onMessage(c, message: { body: { amount: number } }, { signal }) { + expectTypeOf(c.state.count).toEqualTypeOf(); + expectTypeOf(signal).toEqualTypeOf(); + expectTypeOf(c.stream.append) + .parameter(0) + .toEqualTypeOf<{ amount: number }>(); + c.state.count += message.body.amount; + }, + }, + actions: { count: (c) => c.state.count }, +}); +expectTypeOf>().toEqualTypeOf<{ + amount: number; +}>(); +declare const typedHandle: ActorHandle; +typedHandle.stream.append({ amount: 1 }); +// @ts-expect-error body inferred from the handler +typedHandle.stream.append({ amount: "invalid" }); +expectTypeOf(typedHandle.count()).toEqualTypeOf>(); + +const validated = actor({ + state: { count: 0 }, + stream: { + schema: z.object({ amount: z.string().transform(Number) }), + async onMessage(c, { body }) { + expectTypeOf(body.amount).toEqualTypeOf(); + expectTypeOf(c.stream.append) + .parameter(0) + .toEqualTypeOf<{ amount: string }>(); + c.state.count += body.amount; + }, + }, +}); +expectTypeOf>().toEqualTypeOf<{ + amount: string; +}>(); +declare const validatedHandle: ActorHandle; +validatedHandle.stream.append({ amount: "1" }); +// @ts-expect-error append accepts schema input, not transformed output +validatedHandle.stream.append({ amount: 1 }); + +actor({ + stream: { + onMessage(_c, { body }) { + expectTypeOf(body).toEqualTypeOf(); + }, + }, +}); + +actor({ + state: { count: 0 }, + subscriptions: { + orders: { + stream: "/tenants/:tenant/orders/:customer", + actorKey: [":tenant", ":customer"], + schema: z.object({ amount: z.string().transform(Number) }), + onMessage(c, { body }, { signal }) { + expectTypeOf(c.state.count).toEqualTypeOf(); + expectTypeOf(body.amount).toEqualTypeOf(); + expectTypeOf(signal).toEqualTypeOf(); + c.state.count += body.amount; + }, + }, + raw: { + stream: "/raw/:customer", + actorKey: [":customer"], + onMessage(c, message: { body: { amount: number } }) { + c.state.count += message.body.amount; + }, + }, + }, +}); + +expectTypeOf(typedHandle.stream.status()).toEqualTypeOf< + Promise +>(); +expectTypeOf(typedHandle.stream.resume()).toEqualTypeOf>(); + +const combined = actor({ + state: { count: 0 }, + stream: { + onMessage(c, { body }: { body: { amount: number } }) { + c.state.count += body.amount; + }, + }, + subscriptions: { + orders: { + stream: "/orders/:customer", + actorKey: [":customer"], + schema: z.object({ amount: z.number() }), + onError(c, failure) { + expectTypeOf(c.state.count).toEqualTypeOf(); + expectTypeOf(failure.stream).toEqualTypeOf(); + }, + onMessage(c, { body }) { + expectTypeOf(body.amount).toEqualTypeOf(); + c.state.count += body.amount; + }, + }, + }, +}); +expectTypeOf>().toEqualTypeOf<{ amount: number }>(); +declare const combinedHandle: ActorHandle; +combinedHandle.stream.append({ amount: 1 }); +// @ts-expect-error Combining primary and named handlers preserves the primary input. +combinedHandle.stream.append({ amount: "wrong" }); diff --git a/rivetkit-typescript/packages/rivetkit/tests/fixtures/actor-streams-server.ts b/rivetkit-typescript/packages/rivetkit/tests/fixtures/actor-streams-server.ts new file mode 100644 index 0000000000..cd8e5128c4 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/fixtures/actor-streams-server.ts @@ -0,0 +1,168 @@ +import { z } from "zod/v4"; +import { actor, setup } from "../../src/mod"; +import { buildNativeRegistry } from "../../src/registry/native"; + +const worker = actor({ + state: { messages: [] as number[] }, + options: { sleepTimeout: 500 }, + stream: { + async onMessage( + c, + message: { body: { id: number; delay?: number } }, + { signal }, + ) { + if (message.body.delay) + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, message.body.delay); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); + }); + c.state.messages.push(message.body.id); + }, + }, + actions: { + snapshot: (c) => [...c.state.messages], + sleep: async (c) => { + await c.sleep(); + }, + appendSelf: async (c, id: number) => { + await c.stream.append({ id }); + }, + }, +}); + +const validated = actor({ + state: { messages: [] as number[] }, + options: { sleepTimeout: 500 }, + stream: { + schema: z.object({ id: z.string().transform(Number) }), + onMessage(c, { body }) { + c.state.messages.push(body.id); + }, + }, + actions: { snapshot: (c) => [...c.state.messages] }, +}); + +const recovering = actor({ + state: { + messages: [] as number[], + blocked: true, + failed: false, + errors: 0, + }, + options: { sleepTimeout: 500 }, + stream: { + onError(c) { + c.state.errors += 1; + }, + onMessage(c, { body }: { body: { id: number } }) { + if (body.id === 8 && c.state.blocked) { + c.state.failed = true; + throw new Error("Intentional test failure"); + } + c.state.messages.push(body.id); + }, + }, + actions: { + snapshot: (c) => ({ + messages: [...c.state.messages], + failed: c.state.failed, + errors: c.state.errors, + }), + unblock: (c) => { + c.state.blocked = false; + }, + }, +}); + +const deadLetters = actor({ + state: { messages: [] as number[], blocked: true, errors: 0 }, + options: { sleepTimeout: 500 }, + stream: { + deadLetter: true, + onError(c) { + c.state.errors += 1; + }, + onMessage(c, { body }: { body: { id: number } }) { + if (body.id === 8 && c.state.blocked) + throw new Error("Dead-letter test failure"); + c.state.messages.push(body.id); + }, + }, + actions: { + snapshot: (c) => ({ + messages: [...c.state.messages], + errors: c.state.errors, + }), + unblock: (c) => { + c.state.blocked = false; + }, + }, +}); + +const version = Number(process.env.RIVET_STREAMS_TEST_VERSION ?? "1"); +const customer = actor({ + state: { messages: [] as string[] }, + options: { sleepTimeout: 500 }, + stream: { + onMessage(c, { body }: { body: { id: string } }) { + c.state.messages.push(`primary:${body.id}`); + }, + }, + subscriptions: { + orders: { + stream: "/tenants/:tenant/orders/:customer/:source", + actorKey: [":tenant", ":customer"], + schema: z.object({ id: z.string() }), + onMessage(c, { body }) { + if (body.id === "poison") throw new Error("Poison order"); + c.state.messages.push(`orders:${body.id}`); + }, + }, + notifications: { + stream: "/tenants/:tenant/notifications/:customer", + actorKey: [":tenant", ":customer"], + onMessage(c, { body }: { body: { id: string } }) { + c.state.messages.push(`notifications:${body.id}`); + }, + }, + }, + actions: { snapshot: (c) => [...c.state.messages] }, +}); +// Deploy the same fixture with successive complete declaration sets. +if (version < 2) delete customer.config.subscriptions!.notifications; +if (version >= 3) delete customer.config.subscriptions!.orders; +const actors = { worker, validated, recovering, customer, deadLetters }; +if (version >= 4) Reflect.deleteProperty(actors, "customer"); + +const registry = setup({ + use: actors, + endpoint: process.env.RIVET_ENDPOINT!, + token: process.env.RIVET_TOKEN!, + namespace: "default", + startServices: false, + sqlite: "remote", + envoy: { poolName: process.env.RIVET_STREAMS_TEST_POOL!, version }, + durableStreams: { + endpoint: process.env.RIVET_STREAMS_TEST_ROOT!, + }, +}); +export type Registry = typeof registry; + +const { + runtime, + registry: nativeRegistry, + serveConfig, +} = await buildNativeRegistry(registry.parseConfig()); +const shutdown = () => { + void runtime.shutdownRegistry(nativeRegistry); +}; +process.once("SIGTERM", shutdown); +process.once("SIGINT", shutdown); +await runtime.serveRegistry(nativeRegistry, serveConfig); diff --git a/rivetkit-typescript/packages/rivetkit/tests/lifecycle-errors.test.ts b/rivetkit-typescript/packages/rivetkit/tests/lifecycle-errors.test.ts index 6d8b4c80de..9e79dfe4d2 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/lifecycle-errors.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/lifecycle-errors.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "vitest"; import { ActorError } from "../src/client/errors"; -import { isRetryableLifecycleRequestError } from "../src/client/lifecycle-errors"; +import { + isRetryableLifecycleRequestError, + retryOnLifecycleBoundary, +} from "../src/client/lifecycle-errors"; describe("lifecycle error retry classification", () => { test.each([ @@ -31,3 +34,30 @@ describe("lifecycle error retry classification", () => { ).toBe(false); }); }); + +test("retries a stale-generation rejection before application admission", async () => { + let attempts = 0; + const value = await retryOnLifecycleBoundary( + async () => { + if (++attempts === 1) + throw new ActorError( + "envoy", + "actor_generation_mismatch", + "Actor generation does not match", + ); + return "accepted"; + }, + { initialDelayMs: 1 }, + ); + expect(value).toBe("accepted"); + expect(attempts).toBe(2); + expect( + isRetryableLifecycleRequestError( + new ActorError( + "envoy", + "request_delivery_unconfirmed", + "Unknown delivery", + ), + ), + ).toBe(false); +}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts b/rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts index b817c8abf5..e8f4e4df93 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/shared-engine.ts @@ -40,6 +40,9 @@ export interface SharedTestEngine { export interface SharedTestEngineOptions { gateway3?: boolean; + engineBinaryPath?: string; + /** Keep integration timing independent of host CPU load. */ + deterministicScheduling?: boolean; } interface SharedEngineFiles { @@ -57,7 +60,11 @@ let sharedEngineProfile: string | undefined; let sharedEngineFiles: SharedEngineFiles | undefined; function resolveSharedEngineProfile(options: SharedTestEngineOptions): string { - return JSON.stringify({ gateway3: options.gateway3 ?? false }); + return JSON.stringify({ + gateway3: options.gateway3 ?? false, + engineBinaryPath: options.engineBinaryPath, + deterministicScheduling: options.deterministicScheduling ?? false, + }); } function resolveSharedEngineFiles(profile: string): SharedEngineFiles { @@ -278,6 +285,17 @@ async function spawnSharedEngine( writeFileSync( configPath, JSON.stringify({ + runtime: options.deterministicScheduling + ? { + worker_load_shedding_curve: [ + // Engine requires a strictly descending curve. Both ends + // exceed every sampling bucket (0..999), disabling shedding. + [0, 1001], + [1000, 1000], + ], + worker_poll_interval_ms: 100, + } + : undefined, topology: { datacenter_label: 1, datacenters: { @@ -311,7 +329,7 @@ async function spawnSharedEngine( const spawnStartedAt = performance.now(); const logs: RuntimeLogs = { stdout: "", stderr: "" }; const engine = spawn( - resolveEngineBinaryPath(), + options.engineBinaryPath ?? resolveEngineBinaryPath(), ["start", "--config", configPath], { env: { diff --git a/rivetkit-typescript/packages/rivetkit/tests/token-issuance.test.ts b/rivetkit-typescript/packages/rivetkit/tests/token-issuance.test.ts index c2e629a29a..e863f7c36e 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/token-issuance.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/token-issuance.test.ts @@ -11,20 +11,29 @@ function jsonResponse(body: unknown, status = 200): Response { } function issuedResponse(token = "scoped-token") { - return jsonResponse({ token, issued_ts: 1_700_000_000_000, expires_ts: 1_700_000_030_000 }); + return jsonResponse({ + token, + issued_ts: 1_700_000_000_000, + expires_ts: 1_700_000_030_000, + }); } test("client auth uses endpoint credentials and explicit grants without a duration default", async () => { const requests: Request[] = []; - vi.stubGlobal("fetch", vi.fn(async (request: Request) => { - requests.push(request); - return issuedResponse(); - })); + vi.stubGlobal( + "fetch", + vi.fn(async (request: Request) => { + requests.push(request); + return issuedResponse(); + }), + ); const client = createClient({ endpoint: "https://my-namespace:issuer-secret@api.example.com", disableMetadataLookup: true, }); - const grants = [{ resource: "actor", target: "any", operations: ["create"] }] as const; + const grants = [ + { resource: "actor", target: "any", operations: ["create"] }, + ] as const; const result = await client.auth.issueToken({ grants: [...grants] }); expect(result).toEqual({ @@ -34,16 +43,24 @@ test("client auth uses endpoint credentials and explicit grants without a durati }); expect(requests).toHaveLength(1); expect(new URL(requests[0]!.url).pathname).toBe("/auth/tokens"); - expect(requests[0]!.headers.get("authorization")).toBe("Bearer issuer-secret"); - expect(await requests[0]!.json()).toEqual({ namespace: "my-namespace", grants }); + expect(requests[0]!.headers.get("authorization")).toBe( + "Bearer issuer-secret", + ); + expect(await requests[0]!.json()).toEqual({ + namespace: "my-namespace", + grants, + }); }); test("actor handles issue ID-scoped gateway grants by default", async () => { const bodies: unknown[] = []; - vi.stubGlobal("fetch", vi.fn(async (request: Request) => { - bodies.push(await request.json()); - return issuedResponse(); - })); + vi.stubGlobal( + "fetch", + vi.fn(async (request: Request) => { + bodies.push(await request.json()); + return issuedResponse(); + }), + ); const client = createClient({ endpoint: "https://api.example.com", namespace: "team", @@ -56,27 +73,50 @@ test("actor handles issue ID-scoped gateway grants by default", async () => { }); expect(result.token).toBe("scoped-token"); - expect(bodies).toEqual([{ - namespace: "team", - subject: "user-1", - duration: 30, - grants: [{ resource: "actor_gateway", target: { id: "actor-123" }, operations: ["read"] }], - }]); + expect(bodies).toEqual([ + { + namespace: "team", + subject: "user-1", + duration: 30, + grants: [ + { + resource: "actor_gateway", + target: { id: "actor-123" }, + operations: ["read", "append"], + }, + { + resource: "actor_stream", + target: { id: "actor-123" }, + operations: ["append"], + }, + ], + }, + ]); }); test("actor permissions replace the default and stay scoped to the resolved ID", async () => { const tokenBodies: unknown[] = []; - vi.stubGlobal("fetch", vi.fn(async (request: Request) => { - const url = new URL(request.url); - if (url.pathname === "/actors" && request.method === "GET") { - return jsonResponse({ actors: [{ actor_id: "resolved-id", name: "user", key: "key" }] }); - } - if (url.pathname === "/actors" && request.method === "PUT") { - return jsonResponse({ actor: { actor_id: "created-id", name: "user", key: "key" }, created: true }); - } - tokenBodies.push(await request.json()); - return issuedResponse(); - })); + vi.stubGlobal( + "fetch", + vi.fn(async (request: Request) => { + const url = new URL(request.url); + if (url.pathname === "/actors" && request.method === "GET") { + return jsonResponse({ + actors: [ + { actor_id: "resolved-id", name: "user", key: "key" }, + ], + }); + } + if (url.pathname === "/actors" && request.method === "PUT") { + return jsonResponse({ + actor: { actor_id: "created-id", name: "user", key: "key" }, + created: true, + }); + } + tokenBodies.push(await request.json()); + return issuedResponse(); + }), + ); const client = createClient({ endpoint: "https://api.example.com", namespace: "team", @@ -84,7 +124,10 @@ test("actor permissions replace the default and stay scoped to the resolved ID", disableMetadataLookup: true, }); await client.user.get("key").issueToken({ - permissions: { actor: ["read", "update", "delete"], actor_kv: ["read"] }, + permissions: { + actor: ["read", "update", "delete"], + actor_kv: ["read"], + }, }); await client.user.getOrCreate("other").issueToken({ permissions: { actor_gateway: ["read"] }, @@ -94,56 +137,145 @@ test("actor permissions replace the default and stay scoped to the resolved ID", { namespace: "team", grants: [ - { resource: "actor", target: { id: "resolved-id" }, operations: ["read", "update", "delete"] }, - { resource: "actor_kv", target: { id: "resolved-id" }, operations: ["read"] }, + { + resource: "actor", + target: { id: "resolved-id" }, + operations: ["read", "update", "delete"], + }, + { + resource: "actor_kv", + target: { id: "resolved-id" }, + operations: ["read"], + }, ], }, { namespace: "team", - grants: [{ resource: "actor_gateway", target: { id: "created-id" }, operations: ["read"] }], + grants: [ + { + resource: "actor_gateway", + target: { id: "created-id" }, + operations: ["read"], + }, + ], }, ]); }); test("issuance waits for metadata endpoint and credential resolution", async () => { const requests: Request[] = []; - vi.stubGlobal("fetch", vi.fn(async (request: Request) => { - requests.push(request); - if (new URL(request.url).pathname === "/metadata") { - return jsonResponse({ - clientEndpoint: "https://routed.example.com", - clientNamespace: "routed-namespace", - clientToken: "routed-token", - }); - } - return issuedResponse(); - })); - const client = createClient({ endpoint: "https://metadata-issuance.example.com" }); - await client.auth.issueToken({ grants: [{ resource: "actor", target: "any", operations: ["create"] }] }); + vi.stubGlobal( + "fetch", + vi.fn(async (request: Request) => { + requests.push(request); + if (new URL(request.url).pathname === "/metadata") { + return jsonResponse({ + clientEndpoint: "https://routed.example.com", + clientNamespace: "routed-namespace", + clientToken: "routed-token", + }); + } + return issuedResponse(); + }), + ); + const client = createClient({ + endpoint: "https://metadata-issuance.example.com", + }); + await client.auth.issueToken({ + grants: [{ resource: "actor", target: "any", operations: ["create"] }], + }); expect(requests.map((request) => new URL(request.url).host)).toEqual([ "metadata-issuance.example.com", "routed.example.com", ]); - expect(requests[1]!.headers.get("authorization")).toBe("Bearer routed-token"); + expect(requests[1]!.headers.get("authorization")).toBe( + "Bearer routed-token", + ); expect((await requests[1]!.json()).namespace).toBe("routed-namespace"); }); test("issuance preserves structured Engine errors", async () => { - vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ - group: "auth", - code: "insufficient_permissions", - message: "Cannot delegate grant", - }, 403))); + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse( + { + group: "auth", + code: "insufficient_permissions", + message: "Cannot delegate grant", + }, + 403, + ), + ), + ); const client = createClient({ endpoint: "https://api.example.com", disableMetadataLookup: true, }); - await expect(client.auth.issueToken({ - grants: [{ resource: "actor", target: "any", operations: ["create"] }], - })).rejects.toMatchObject({ + await expect( + client.auth.issueToken({ + grants: [ + { resource: "actor", target: "any", operations: ["create"] }, + ], + }), + ).rejects.toMatchObject({ group: "auth", code: "insufficient_permissions", statusCode: 403, }); }); + +test("client auth pairs actor input with primary append without widening other operations", async () => { + const bodies: any[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (request: Request) => { + bodies.push(await request.json()); + return issuedResponse(); + }), + ); + const client = createClient({ + endpoint: "https://api.gameinc.io", + namespace: "team", + token: "issuer", + disableMetadataLookup: true, + }); + await client.auth.issueToken({ + grants: [ + { + resource: "actor_gateway", + target: { id: "actor-a" }, + operations: ["append"], + }, + ], + }); + expect(bodies[0].grants).toEqual([ + { + resource: "actor_gateway", + target: { id: "actor-a" }, + operations: ["append"], + }, + { + resource: "actor_stream", + target: { id: "actor-a" }, + operations: ["append"], + }, + ]); + await client.auth.issueToken({ + grants: [ + { + resource: "actor_gateway", + target: { id: "actor-a" }, + operations: ["read"], + }, + ], + }); + expect(bodies[1].grants).toEqual([ + { + resource: "actor_gateway", + target: { id: "actor-a" }, + operations: ["read"], + }, + ]); +}); diff --git a/rivetkit-typescript/packages/rivetkit/tsconfig.json b/rivetkit-typescript/packages/rivetkit/tsconfig.json index cb0ff5d4b0..b69b407118 100644 --- a/rivetkit-typescript/packages/rivetkit/tsconfig.json +++ b/rivetkit-typescript/packages/rivetkit/tsconfig.json @@ -20,6 +20,7 @@ "src/**/*", "runtime/index.ts", "tests/token-issuance.types.ts" + ,"tests/actor-streams.types.ts" ], "exclude": [ "src/actor/instance/mod.ts", diff --git a/rivetkit-typescript/packages/rivetkit/vitest.config.ts b/rivetkit-typescript/packages/rivetkit/vitest.config.ts index 1f12fdf1d3..8f2872e492 100644 --- a/rivetkit-typescript/packages/rivetkit/vitest.config.ts +++ b/rivetkit-typescript/packages/rivetkit/vitest.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ ...defaultConfig.test, exclude: [ ...configDefaults.exclude, + ...(process.env.RIVETKIT_INCLUDE_STREAM_TESTS === "1" + ? [] + : ["tests/actor-streams.integration.test.ts"]), ...(process.env.RIVETKIT_INCLUDE_PLATFORM_TESTS === "1" ? [] : ["tests/platforms/**/*.test.ts"]),