Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ docker-compose up -d

### RivetKit Test Fixtures

- Core tests that touch the `_RIVET_TEST_INSPECTOR_TOKEN` env override must share a process-wide lock with startup tests that assert inspector-token initialization side effects; otherwise parallel `cargo test` runs can flip `init_inspector_token(...)` between the env-override no-op path and the KV-backed path.
- Core tests that touch the `_RIVET_TEST_INSPECTOR_TOKEN` env override must share a process-wide lock with startup tests that assert inspector-token initialization side effects; otherwise parallel `cargo test` runs can flip `init_inspector_token(...)` between the env-override no-op path and the stored-token initialization path.
- For the fast static/http/bare driver verifier, pass only the files listed under `## Fast Tests` in `~/.agents/notes/driver-test-progress.md`; `tests/driver/*.test.ts` also pulls in slow-suite files and gives bogus gate failures.
- Wasm host smoke tests can drive `buildNativeFactory` through `WasmCoreRuntime` fake bindings to cover actor callbacks, KV, state serialization, remote SQLite routing, and NAPI import boundaries without checked-in wasm-pack output.
- When moving Rust inline tests out of `src/`, keep a tiny source-owned `#[cfg(test)] #[path = "..."] mod tests;` shim so the moved file still has private module access without widening runtime visibility. Prefer a dedicated moved-test file per source module; reusing stale shared `tests/modules/*.rs` files can silently rot against private APIs and explode once you wire them back in.
Expand Down Expand Up @@ -315,7 +315,6 @@ When the user asks to track something in a note, store it in `~/.agents/notes/`

## Traces Package

- Keep `@rivetkit/traces` chunk writes under the 128 KiB actor KV value limit. Use 96 KiB chunks unless a multipart reader/writer replaces the single-value format.

## Naming + Data Conventions

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs-internal/engine/ACTOR_KV.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Actor KV Storage

Each actor has its own private KV store which can be manipulated or accessed with the various provided KV operations.
Each actor has its own private KV store which can be manipulated or accessed with the engine KV operations.

For current RivetKit actors, runtime persistence no longer uses this store directly. State, connections, queues, workflow storage, alarms, and deprecated user `c.kv` data live in internal SQLite tables after the first wake on the migrated runtime. Legacy actor KV data remains as a frozen downgrade snapshot, and the inspector token is mirrored to KV for dashboard compatibility.

## Keys and Values

Expand Down
48 changes: 26 additions & 22 deletions docs-internal/engine/rivetkit-core-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,34 @@ Actor subsystems are composed into `ActorContextInner`, not separate managers.
- Schedule storage lives on `ActorContextInner`. Behavior sits in `actor/schedule.rs` `impl ActorContext` blocks. Do not reintroduce `Arc<ScheduleInner>` or a public `Schedule` re-export.
- Event fanout lives directly in `ActorContext::broadcast`. Do not reintroduce a separate `EventBroadcaster` subsystem.

## Persisted KV layout
## Internal SQLite storage

Values are serialized with a vbare-compatible 2-byte little-endian embedded version prefix before the BARE body, matching the TypeScript `serializeWithEmbeddedVersion(...)` format.
Runtime actor persistence lives in internal SQLite tables. The `_rivet_` table prefix is reserved for RivetKit runtime data and must not be used by user schemas.

| Key | Contents |
| Table | Contents |
|---|---|
| `[1]` | `PersistedActor` snapshot (matches TypeScript `KEYS.PERSIST_DATA`) |
| `[2] + conn_id` | Hibernatable websocket connection payload, TypeScript v4 BARE field order |
| `[5, 1, 1]` | Queue metadata |
| `[5, 1, 2] + u64be(id)` | Queue messages (FIFO prefix scan) |
| `[6]` | `LAST_PUSHED_ALARM_KEY` — `Option<i64>` last pushed driver alarm |
| `_rivet_actor` | Cold actor startup fields such as `has_initialized` and input |
| `_rivet_actor_state` | Hot serialized user state |
| `_rivet_schedule_events` | Durable scheduled actions |
| `_rivet_conns` / `_rivet_conn_state` | Hibernatable websocket cold metadata and hot state |
| `_rivet_runtime` | Runtime singletons: last pushed alarm, inspector token, queue next id |
| `_rivet_queue` | Queue messages |
| `_rivet_wf_kv` | TypeScript workflow storage with verbatim packed keys |
| `_rivet_user_kv` | Deprecated user `c.kv` compatibility storage |

Preload handling is tri-state for each prefix:
## Legacy KV import

- `[1]`: no bundle falls back to KV, requested-but-absent means fresh actor defaults, present decodes the persisted actor.
- `[2] + conn_id`: consumed from preload when `PreloadedKv.requested_prefixes` includes `[2]`; fall back to `kv.list_prefix([2])` only when that prefix is absent.
- `[5, 1, 1]` + `[5, 1, 2]`: consumed from preload when requested; fall back to KV only when absent.
The first wake on the migrated runtime runs `actor/migrate_kv_to_sqlite/` before actor readiness. The importer live-scans the legacy actor KV keyspace, copies recognized records into SQLite, marks `_rivet_meta.kv_import_state = done`, and leaves legacy KV bytes untouched as a frozen downgrade snapshot.

Legacy traces under `[7, 1, ...]` are intentionally skipped and are not copied to `_rivet_user_kv`. The only runtime KV write after migration is the inspector token mirror at `[3]`, kept until the dashboard stops fetching the token through the public actor-KV endpoint.

## State persistence flow

- `request_save` uses `RequestSaveOpts { immediate, max_wait_ms }`. NAPI callers use `ctx.requestSave({ immediate, maxWaitMs })`. Do not use a boolean `requestSave` or `requestSaveWithin`.
- Receive-loop persistence routes deferred saves through `ActorContext::request_save(...)` + `ActorEvent::SerializeState { reason: Save, .. }`.
- Shutdown adapters persist explicitly with `ActorContext::save_state(Vec<StateDelta>)` because `Sleep`/`Destroy` replies are unit-only. Direct durability must still clear pending save-request flags after a successful write.
- Actor state is post-boot delta-only. Use `request_save` / `save_state(Vec<StateDelta>)`. Do not reintroduce `set_state` / `mutate_state`.
- Schedule mutations update `ActorState` through a single helper, then immediately kick `save_state(immediate = true)` and resync the envoy alarm to the earliest event.
- Schedule mutations write point rows in `_rivet_schedule_events`, then resync the envoy alarm to the earliest event.
- State mutations from inside `on_state_change` callbacks fail with `actor/state_mutation_reentrant`. Use vars or another non-state side channel for callback-run counters.

## Inspector wiring
Expand All @@ -47,7 +50,7 @@ Preload handling is tri-state for each prefix:
## Schedule + alarms

- `Schedule` alarm sync is guarded by `dirty_since_push`. Fresh schedules start dirty, mutations set dirty, and unchanged shutdown syncs must not re-push identical envoy alarms.
- Persisted driver-alarm dedup stores the last pushed `Option<i64>` at actor KV key `[6]`. Startup loads it with `PERSIST_DATA_KEY` and skips identical future alarm pushes.
- Persisted driver-alarm dedup stores the last pushed `Option<i64>` in `_rivet_runtime.last_pushed_alarm`. Startup loads it from SQLite and skips identical future alarm pushes.

## Transport helpers

Expand All @@ -67,14 +70,15 @@ Preload handling is tri-state for each prefix:

## Startup sequence

1. Load `PersistedActor` into `ActorContext` before factory creation.
2. Persist `has_initialized` immediately.
3. Resync persisted alarms and restore hibernatable connections.
4. Set `ready` before the driver hook.
5. Reset the sleep timer.
6. Spawn `run` in a detached panic-catching task.
7. Drain overdue scheduled events after `started`.
8. Set `started` after the driver hook completes.
1. Ensure the internal SQLite schema and import legacy KV to SQLite if needed.
2. Load actor startup data from internal SQLite into `ActorContext` before factory creation.
3. Persist `has_initialized` immediately.
4. Resync persisted alarms and restore hibernatable connections.
5. Set `ready` before the driver hook.
6. Reset the sleep timer.
7. Spawn `run` in a detached panic-catching task.
8. Drain overdue scheduled events after `started`.
9. Set `started` after the driver hook completes.

## Shutdown sequences

Expand Down
14 changes: 7 additions & 7 deletions docs-internal/engine/rivetkit-core-state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This page is the short version of the actor state contract shared by `rivetkit-c

## Ownership

- `rivetkit-core` owns persistence, save scheduling, KV writes, save completion tracking, and persisted connection/schedule metadata.
- `rivetkit-core` owns persistence, save scheduling, SQLite writes, save completion tracking, and persisted connection/schedule metadata.
- The foreign runtime owns user-level state serialization. For TypeScript actors, JS keeps the live `c.state` object and returns encoded deltas through the NAPI `serializeState` callback.
- NAPI only translates between JS values and core types. It should not decide whether state is dirty, when a save is durable, or how deltas are applied.

Expand All @@ -14,25 +14,25 @@ This page is the short version of the actor state contract shared by `rivetkit-c
- `ActorContext::request_save(RequestSaveOpts { immediate, max_wait_ms })` is a save hint. It marks a save request, emits `LifecycleEvent::SaveRequested`, and lets the runtime serialize state later.
- `ActorContext::request_save_and_wait(opts)` uses the same request path, then waits until the matching save request revision completes. TypeScript uses this for immediate durable saves.
- `ActorContext::save_state(Vec<StateDelta>)` applies structured runtime output. Deltas can replace the actor-state blob, persist hibernatable connection bytes, or remove hibernation records.
- `ActorContext::persist_state(SaveStateOpts)` is internal core persistence for core-owned dirty data, shutdown cleanup, and schedule metadata. It persists the current `PersistedActor` snapshot and should not become a user-facing runtime mutation API.
- `ActorContext::persist_state(SaveStateOpts)` is internal core persistence for core-owned dirty data, shutdown cleanup, and schedule metadata. It persists structured internal SQLite rows and should not become a user-facing runtime mutation API.

## Save Flow

1. User code mutates runtime-owned state, such as the TypeScript `c.state` object.
2. The runtime calls `request_save(...)`, or core calls it after core-owned hibernatable connection state changes.
3. `ActorTask` receives `LifecycleEvent::SaveRequested` and dispatches `SerializeState { reason: Save }` through the foreign-runtime callback.
4. The runtime returns `StateDelta` values.
5. Core applies those deltas with `save_state(...)`, writes the encoded records to KV, updates in-memory snapshots, and marks the save request revision complete.
5. Core applies those deltas with `save_state(...)`, writes the encoded records to internal SQLite tables, updates in-memory snapshots, and marks the save request revision complete.

Immediate saves are the same flow with a zero debounce and a waiter. They must not bypass `serializeState`.

## Delta Contract

- `StateDelta::ActorState(bytes)` replaces the persisted actor-state blob under the single-byte KV key `[1]`.
- `StateDelta::ConnHibernation { conn, bytes }` writes hibernatable connection state under the connection KV prefix.
- `StateDelta::ConnHibernationRemoved(conn)` removes a persisted hibernatable connection record.
- `StateDelta::ActorState(bytes)` replaces the hot actor-state row in `_rivet_actor_state`.
- `StateDelta::ConnHibernation { conn, bytes }` upserts the connection cold row in `_rivet_conns` and hot row in `_rivet_conn_state`.
- `StateDelta::ConnHibernationRemoved(conn)` removes the persisted hibernatable connection rows.

Core prepares the write batch while holding the save guard, then releases the guard before awaiting KV. Waiters that need durability use save-request revisions or the in-flight write counter rather than holding the save guard across I/O.
Core prepares the SQL batch while holding the save guard, then releases the guard before awaiting SQLite. Waiters that need durability use save-request revisions or the in-flight write counter rather than holding the save guard across I/O.

## Do Not Reintroduce

Expand Down
2 changes: 1 addition & 1 deletion docs-internal/rivetkit-typescript/ACTOR_KV_STRUCTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Actor KV Structure

This document is the canonical reference for the actor KV keyspace in `rivetkit-typescript`.
This is a legacy reference for the frozen actor KV keyspace used by pre-SQLite RivetKit actors. Current RivetKit runtime persistence lives in internal SQLite tables in `rivetkit-core`; the first wake on the migrated runtime imports these keys into SQLite and leaves the KV bytes untouched for downgrade safety. The only live post-migration write to this keyspace is the inspector token mirror at `[3]`.

## Master Tree

Expand Down
5 changes: 5 additions & 0 deletions rivetkit-rust/engine/artifacts/errors/kv.value_too_large.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"code": "value_too_large",
"group": "kv",
"message": "KV value is too large."
}
5 changes: 5 additions & 0 deletions rivetkit-rust/engine/artifacts/errors/sqlite.writer_busy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"code": "writer_busy",
"group": "sqlite",
"message": "SQLite writer is busy."
}
1 change: 1 addition & 0 deletions rivetkit-rust/packages/rivetkit-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ web-time = "1.1"

[dev-dependencies]
portpicker.workspace = true
rusqlite.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["test-util"] }
tracing-subscriber.workspace = true
Expand Down
74 changes: 44 additions & 30 deletions rivetkit-rust/packages/rivetkit-core/src/actor/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use tokio::sync::oneshot;

use crate::actor::config::ActorConfig;
use crate::actor::context::ActorContext;
use crate::actor::internal_storage;
use crate::actor::keys::CONN_PREFIX;
use crate::actor::lifecycle_hooks::Reply;
use crate::actor::messages::{ActorEvent, Request};
Expand All @@ -26,6 +27,7 @@ use crate::actor::persist::{
use crate::actor::preload::PreloadedKv;
use crate::actor::state::RequestSaveOpts;
use crate::error::ActorRuntime;
use crate::sqlite::SqliteBackend;
use crate::time::timeout;
use crate::types::ConnId;
use crate::types::ListOpts;
Expand Down Expand Up @@ -727,40 +729,52 @@ impl ActorContext {
&self,
preloaded_kv: Option<&PreloadedKv>,
) -> Result<Vec<ConnHandle>> {
let entries =
if let Some(entries) = preloaded_kv.and_then(|kv| kv.prefix_entries(&CONN_PREFIX)) {
entries
let persisted_connections =
if self.sql().backend() == SqliteBackend::Unavailable {
let entries =
if let Some(entries) = preloaded_kv.and_then(|kv| kv.prefix_entries(&CONN_PREFIX))
{
entries
} else {
self.0
.kv
.list_prefix(
&CONN_PREFIX,
ListOpts {
reverse: false,
limit: None,
},
)
.await?
};

let mut persisted_connections = Vec::new();
for (_key, value) in entries {
match decode_persisted_connection(&value) {
Ok(persisted) => persisted_connections.push(persisted),
Err(error) => {
tracing::error!(?error, "failed to decode persisted connection");
}
}
}
persisted_connections
} else {
self.0
.kv
.list_prefix(
&CONN_PREFIX,
ListOpts {
reverse: false,
limit: None,
},
)
.await?
internal_storage::load_connections(self.sql())
.await
.context("load hibernatable connections from sqlite")?
};
let mut restored = Vec::new();

for (_key, value) in entries {
match decode_persisted_connection(&value) {
Ok(persisted) => {
let conn = ConnHandle::from_persisted(persisted);
self.prepare_managed_conn(&conn);
self.insert_existing(conn.clone());
tracing::debug!(
actor_id = %self.actor_id(),
conn_id = conn.id(),
"hibernatable connection restored"
);
restored.push(conn);
}
Err(error) => {
tracing::error!(?error, "failed to decode persisted connection");
}
}
for persisted in persisted_connections {
let conn = ConnHandle::from_persisted(persisted);
self.prepare_managed_conn(&conn);
self.insert_existing(conn.clone());
tracing::debug!(
actor_id = %self.actor_id(),
conn_id = conn.id(),
"hibernatable connection restored"
);
restored.push(conn);
}

Ok(restored)
Expand Down
Loading
Loading