From b50ef9aadfb923e75493143b9be5d82d428ced70 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 05:55:45 -0700 Subject: [PATCH 1/8] feat(rivetkit): add actor scheduling --- Cargo.lock | 35 +- Cargo.toml | 6 + .../docs/actors-crash-course/scheduling.ts | 38 +- examples/docs/actors-schedule/full-example.ts | 60 - .../docs/actors-testing/testing-schedules.ts | 49 +- examples/docs/actors-workflows/cron.ts | 23 +- .../docs/cookbook-cron-jobs/daily-report.ts | 11 +- .../src/actors/idle/world.ts | 30 +- examples/scheduling/src/index.ts | 50 +- frontend/package.json | 1 + .../src/components/actors/actor-database.tsx | 8 +- .../actors/actor-details-shared.tsx | 1 + .../actors/actor-editable-state.tsx | 10 +- .../actors/actor-inspector-context.tsx | 309 +++- .../actors/actor-inspector-protocol.test.ts | 50 + .../actors/actor-schedules-format.ts | 39 + .../actors/actor-schedules-tab.test.ts | 53 + .../components/actors/actor-schedules-tab.tsx | 396 +++++ .../components/actors/actor-status-label.tsx | 3 +- .../actors/console/actor-console-log.tsx | 3 +- .../components/actors/inspector-tab-icons.tsx | 2 + .../actors/inspector-tab-registry.tsx | 18 + frontend/src/components/json/index.tsx | 5 + frontend/src/lib/format-value.test.ts | 31 + frontend/src/lib/format-value.ts | 19 + pnpm-lock.yaml | 9 + .../errors/schedule.interrupted.json | 5 + .../schedule.invalid_cron_expression.json | 5 + .../errors/schedule.invalid_interval.json | 5 + .../errors/schedule.invalid_max_history.json | 5 + .../errors/schedule.invalid_name.json | 5 + .../errors/schedule.invalid_schedule_row.json | 5 + .../errors/schedule.invalid_timezone.json | 5 + .../schedule.max_schedules_exceeded.json | 5 + .../inspector-protocol/schemas/v6.bare | 286 ++++ .../packages/inspector-protocol/src/lib.rs | 4 +- .../inspector-protocol/src/versioned.rs | 332 +++- .../packages/rivetkit-core/Cargo.toml | 3 + .../rivetkit-core/src/actor/config.rs | 7 + .../rivetkit-core/src/actor/context.rs | 90 +- .../src/actor/internal_schema.rs | 54 +- .../src/actor/internal_storage.rs | 119 +- .../rivetkit-core/src/actor/messages.rs | 2 + .../src/actor/migrate_kv_to_sqlite/mod.rs | 2 +- .../rivetkit-core/src/actor/schedule.rs | 1409 ++++++++++++++--- .../packages/rivetkit-core/src/actor/state.rs | 17 - .../packages/rivetkit-core/src/actor/task.rs | 5 +- .../packages/rivetkit-core/src/error.rs | 79 + .../rivetkit-core/src/inspector/mod.rs | 14 + .../rivetkit-core/src/inspector/protocol.rs | 50 +- .../rivetkit-core/src/inspector/tabs.rs | 1 + .../src/registry/actor_connect.rs | 9 - .../src/registry/inspector_ws.rs | 406 ++++- .../packages/rivetkit-core/src/testing.rs | 57 +- .../packages/rivetkit-core/tests/config.rs | 3 + .../packages/rivetkit-core/tests/context.rs | 124 +- .../packages/rivetkit-core/tests/inspector.rs | 10 +- .../tests/integration/counter.rs | 1 + .../integration/sqlite_corruption_fuzz.rs | 1 + .../rivetkit-core/tests/internal_schema.rs | 25 + .../tests/migrate_kv_to_sqlite.rs | 24 +- .../packages/rivetkit-core/tests/schedule.rs | 772 +++++++-- .../packages/rivetkit-core/tests/state.rs | 16 - .../packages/rivetkit-core/tests/task.rs | 63 +- .../packages/rivetkit/src/context.rs | 100 +- rivetkit-rust/packages/rivetkit/src/event.rs | 30 + rivetkit-rust/packages/rivetkit/src/lib.rs | 8 +- rivetkit-rust/packages/rivetkit/src/start.rs | 12 +- .../packages/rivetkit/tests/client.rs | 8 +- .../tests/integration_canned_events.rs | 1 + .../rivetkit/tests/modules/context.rs | 111 +- .../packages/rivetkit-napi/index.d.ts | 46 +- .../rivetkit-napi/src/actor_factory.rs | 17 + .../rivetkit-napi/src/napi_actor_events.rs | 19 +- .../packages/rivetkit-napi/src/schedule.rs | 231 ++- .../rivetkit-napi/tests/napi_actor_events.rs | 3 + .../packages/rivetkit-wasm/index.d.ts | 26 +- .../packages/rivetkit-wasm/src/lib.rs | 147 +- .../packages/rivetkit/src/actor/config.ts | 94 +- .../src/common/bare/generated/inspector/v6.ts | 1367 ++++++++++++++++ .../packages/rivetkit/src/common/encoding.ts | 49 +- .../rivetkit/src/inspector-tab/mod.ts | 1 + .../rivetkit/src/inspector/client.browser.ts | 77 +- .../rivetkit/src/registry/napi-runtime.ts | 102 +- .../packages/rivetkit/src/registry/native.ts | 169 +- .../packages/rivetkit/src/registry/runtime.ts | 85 +- .../rivetkit/src/registry/wasm-runtime.ts | 127 +- .../packages/rivetkit/src/utils.ts | 3 + .../packages/rivetkit/tests/cron-api.test.ts | 142 ++ .../tests/inspector-versioned.test.ts | 46 +- .../rivetkit/tests/package-surface.test.ts | 4 +- .../rivetkit/tests/runtime-parity.test.ts | 44 + website/src/content/cookbook/cron-jobs.mdx | 138 +- .../docs/actors/actor-runtime-socket.mdx | 68 +- .../src/content/docs/actors/connections.mdx | 2 +- .../src/content/docs/actors/crash-course.mdx | 4 +- website/src/content/docs/actors/events.mdx | 2 - website/src/content/docs/actors/limits.mdx | 2 +- website/src/content/docs/actors/queues.mdx | 2 - website/src/content/docs/actors/schedule.mdx | 189 ++- .../content/docs/actors/sqlite-drizzle.mdx | 2 +- website/src/content/docs/actors/sqlite.mdx | 2 +- website/src/content/docs/actors/testing.mdx | 4 +- website/src/sitemap/mod.ts | 12 +- 104 files changed, 7576 insertions(+), 1204 deletions(-) delete mode 100644 examples/docs/actors-schedule/full-example.ts create mode 100644 frontend/src/components/actors/actor-inspector-protocol.test.ts create mode 100644 frontend/src/components/actors/actor-schedules-format.ts create mode 100644 frontend/src/components/actors/actor-schedules-tab.test.ts create mode 100644 frontend/src/components/actors/actor-schedules-tab.tsx create mode 100644 frontend/src/lib/format-value.test.ts create mode 100644 frontend/src/lib/format-value.ts create mode 100644 rivetkit-rust/engine/artifacts/errors/schedule.interrupted.json create mode 100644 rivetkit-rust/engine/artifacts/errors/schedule.invalid_cron_expression.json create mode 100644 rivetkit-rust/engine/artifacts/errors/schedule.invalid_interval.json create mode 100644 rivetkit-rust/engine/artifacts/errors/schedule.invalid_max_history.json create mode 100644 rivetkit-rust/engine/artifacts/errors/schedule.invalid_name.json create mode 100644 rivetkit-rust/engine/artifacts/errors/schedule.invalid_schedule_row.json create mode 100644 rivetkit-rust/engine/artifacts/errors/schedule.invalid_timezone.json create mode 100644 rivetkit-rust/engine/artifacts/errors/schedule.max_schedules_exceeded.json create mode 100644 rivetkit-rust/packages/inspector-protocol/schemas/v6.bare create mode 100644 rivetkit-typescript/packages/rivetkit/src/common/bare/generated/inspector/v6.ts create mode 100644 rivetkit-typescript/packages/rivetkit/tests/cron-api.test.ts diff --git a/Cargo.lock b/Cargo.lock index 0fb33ff876..9f0e656cde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -756,6 +756,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -4209,16 +4219,34 @@ dependencies = [ "sha2", ] +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -6128,7 +6156,10 @@ dependencies = [ "axum 0.8.4", "base64 0.22.1", "bytes", + "chrono", + "chrono-tz", "ciborium", + "croner", "fs_extra", "futures", "getrandom 0.2.16", @@ -7681,7 +7712,7 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf", + "phf 0.13.1", "pin-project-lite", "postgres-protocol", "postgres-types", diff --git a/Cargo.toml b/Cargo.toml index 812a827f37..787c25ae94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -279,6 +279,12 @@ members = [ version = "0.4.38" features = [ "now" ] + [workspace.dependencies.chrono-tz] + version = "0.10.4" + + [workspace.dependencies.croner] + version = "2.2.0" + [workspace.dependencies.clap] version = "4.3" features = [ "derive", "cargo" ] diff --git a/examples/docs/actors-crash-course/scheduling.ts b/examples/docs/actors-crash-course/scheduling.ts index 55455820e1..4e9db663ec 100644 --- a/examples/docs/actors-crash-course/scheduling.ts +++ b/examples/docs/actors-crash-course/scheduling.ts @@ -1,23 +1,23 @@ import { actor, event } from "rivetkit"; const reminder = actor({ - state: { message: "" }, - events: { - reminder: event<{ message: string }>(), - }, - actions: { - // Schedule action to run after delay (ms) - setReminder: (c, message: string, delayMs: number) => { - c.state.message = message; - c.schedule.after(delayMs, "sendReminder"); - }, - // Schedule action to run at specific timestamp - setReminderAt: (c, message: string, timestamp: number) => { - c.state.message = message; - c.schedule.at(timestamp, "sendReminder"); - }, - sendReminder: (c) => { - c.broadcast("reminder", { message: c.state.message }); - }, - }, + state: { message: "Time to check your tasks." }, + events: { + reminder: event<{ message: string }>(), + }, + onCreate: async (c) => { + // Install initial schedules once, when this actor is first created. + await c.schedule.after(30_000, "sendReminder"); + + await c.cron.every({ + name: "reminder-check", + intervalMs: 60_000, + action: "sendReminder", + }); + }, + actions: { + sendReminder: (c) => { + c.broadcast("reminder", { message: c.state.message }); + }, + }, }); diff --git a/examples/docs/actors-schedule/full-example.ts b/examples/docs/actors-schedule/full-example.ts deleted file mode 100644 index 2416c6156b..0000000000 --- a/examples/docs/actors-schedule/full-example.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { actor } from "rivetkit"; - -interface Reminder { - userId: string; - message: string; - scheduledFor: number; -} - -interface ReminderState { - reminders: Record; -} - -// Mock email function -function sendEmail(to: string, message: string) { - console.log(`Sending email to ${to}: ${message}`); -} - -const reminderService = actor({ - state: { reminders: {} } as ReminderState, - - actions: { - setReminder: async (c, userId: string, message: string, delayMs: number) => { - const reminderId = crypto.randomUUID(); - - // Store the reminder in state - c.state.reminders[reminderId] = { - userId, - message, - scheduledFor: Date.now() + delayMs - }; - - // Schedule the sendReminder action to run after the delay - await c.schedule.after(delayMs, "sendReminder", reminderId); - - return { reminderId }; - }, - - sendReminder: (c, reminderId: string) => { - const reminder = c.state.reminders[reminderId]; - if (!reminder) return; - - // Send reminder notification - if (c.conns.size > 0) { - // Send the reminder to all connected clients - for (const conn of c.conns.values()) { - conn.send("reminder", { - message: reminder.message, - scheduledAt: reminder.scheduledFor - }); - } - } else { - // User is offline, send an email notification - sendEmail(reminder.userId, reminder.message); - } - - // Clean up the processed reminder - delete c.state.reminders[reminderId]; - } - } -}); diff --git a/examples/docs/actors-testing/testing-schedules.ts b/examples/docs/actors-testing/testing-schedules.ts index 94ecdfa25b..be0ddceef1 100644 --- a/examples/docs/actors-testing/testing-schedules.ts +++ b/examples/docs/actors-testing/testing-schedules.ts @@ -1,50 +1,29 @@ -import { test, expect } from "vitest"; -import { setupTest } from "rivetkit/test"; +import { expect, test } from "vitest"; import { actor, setup } from "rivetkit"; +import { setupTest } from "rivetkit/test"; -// Helper to wait for a delay -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -// Define the scheduler actor const scheduler = actor({ - state: { - tasks: [] as string[], - completedTasks: [] as string[] - }, + state: { completedTasks: [] as string[] }, actions: { - scheduleTask: (c, taskName: string, delayMs: number) => { - c.state.tasks.push(taskName); - // Schedule "completeTask" to run after the specified delay - c.schedule.after(delayMs, "completeTask", taskName); - return { success: true }; + scheduleTask: async (c, taskName: string) => { + await c.schedule.after(50, "completeTask", taskName); }, completeTask: (c, taskName: string) => { - // This action will be called by the scheduler when the time comes c.state.completedTasks.push(taskName); - return { completed: taskName }; }, - getCompletedTasks: (c) => { - return c.state.completedTasks; - } - } + getCompletedTasks: (c) => c.state.completedTasks, + }, }); -// Create the registry -const registry = setup({ - use: { scheduler } -}); +const registry = setup({ use: { scheduler } }); -// Test scheduled tasks -test("scheduled tasks should execute", async (testCtx) => { +test("scheduled work updates observable state", async (testCtx) => { const { client } = await setupTest(testCtx, registry); - const schedulerHandle = client.scheduler.getOrCreate(["test"]); - - // Set up a scheduled task - await schedulerHandle.scheduleTask("reminder", 100); // 100ms in the future + const handle = client.scheduler.getOrCreate(["test"]); - // Wait for the scheduled task to run - await wait(150); + await handle.scheduleTask("reminder"); - // Verify the scheduled task executed - expect(await schedulerHandle.getCompletedTasks()).toContain("reminder"); + await expect + .poll(() => handle.getCompletedTasks(), { timeout: 2_000, interval: 25 }) + .toContain("reminder"); }); diff --git a/examples/docs/actors-workflows/cron.ts b/examples/docs/actors-workflows/cron.ts index 48deecdc6c..9f9d9abf1c 100644 --- a/examples/docs/actors-workflows/cron.ts +++ b/examples/docs/actors-workflows/cron.ts @@ -1,11 +1,6 @@ -import { actor, queue, setup } from "rivetkit"; +import { actor, queue, setup, type ScheduledFireInfo } from "rivetkit"; import { type WorkflowStepContextOf, workflow } from "rivetkit/workflow"; -function nextMinute(timestamp: number): number { - const minuteMs = 60_000; - return Math.floor(timestamp / minuteMs) * minuteMs + minuteMs; -} - export const cronActor = actor({ state: { runs: 0, @@ -15,15 +10,17 @@ export const cronActor = actor({ "cron-tick": queue<{ scheduledAt: number }>(), }, onCreate: async (c) => { - const firstTickAt = nextMinute(Date.now()); - await c.schedule.at(firstTickAt, "enqueueCronTick", firstTickAt); + await c.cron.every({ + name: "workflow-tick", + intervalMs: 60_000, + action: "enqueueCronTick", + args: [], + maxHistory: 100, + }); }, actions: { - enqueueCronTick: async (c, scheduledAt: number) => { - await c.queue.send("cron-tick", { scheduledAt }); - - const nextTickAt = nextMinute(scheduledAt + 1); - await c.schedule.at(nextTickAt, "enqueueCronTick", nextTickAt); + enqueueCronTick: async (c, fire: ScheduledFireInfo) => { + await c.queue.send("cron-tick", { scheduledAt: fire.scheduledAt }); }, getState: (c) => c.state, }, diff --git a/examples/docs/cookbook-cron-jobs/daily-report.ts b/examples/docs/cookbook-cron-jobs/daily-report.ts index cb94eb6f8c..c434d36c65 100644 --- a/examples/docs/cookbook-cron-jobs/daily-report.ts +++ b/examples/docs/cookbook-cron-jobs/daily-report.ts @@ -1,15 +1,18 @@ import { actor } from "rivetkit"; -const DAY_MS = 24 * 60 * 60 * 1000; - export const dailyReport = actor({ state: { lastRunAt: 0 }, + onCreate: async (c) => { + await c.cron.set({ + name: "daily-report", + expression: "0 9 * * *", + action: "runReport", + }); + }, actions: { runReport: (c) => { // Do the job's work, then record the run. c.state.lastRunAt = Date.now(); - // Re-arm the next run before returning. - c.schedule.after(DAY_MS, "runReport"); }, }, }); diff --git a/examples/multiplayer-game-patterns/src/actors/idle/world.ts b/examples/multiplayer-game-patterns/src/actors/idle/world.ts index e5705506d1..30f759c634 100644 --- a/examples/multiplayer-game-patterns/src/actors/idle/world.ts +++ b/examples/multiplayer-game-patterns/src/actors/idle/world.ts @@ -48,7 +48,7 @@ export const idleWorld = actor({ initialized: false as boolean, } satisfies State, actions: { - initialize: (c, input: { playerName: string; playerId?: string }) => { + initialize: async (c, input: { playerName: string; playerId?: string }) => { if (c.state.initialized) { broadcastState(c); return; @@ -70,11 +70,11 @@ export const idleWorld = actor({ lastCollectedAt: Date.now(), }; c.state.buildings.push(building); - scheduleCollection(c, building.id, farmType.productionIntervalMs); + await scheduleCollection(c, building.id, farmType.productionIntervalMs); updateLeaderboard(c); broadcastState(c); }, - build: (c, input: { buildingTypeId: string }) => { + build: async (c, input: { buildingTypeId: string }) => { const buildingType = BUILDINGS.find( (b: BuildingType) => b.id === input.buildingTypeId, ); @@ -93,7 +93,7 @@ export const idleWorld = actor({ lastCollectedAt: Date.now(), }; c.state.buildings.push(building); - scheduleCollection( + await scheduleCollection( c, building.id, buildingType.productionIntervalMs, @@ -117,8 +117,6 @@ export const idleWorld = actor({ elapsed / buildingType.productionIntervalMs, ); if (intervals <= 0) { - const remaining = buildingType.productionIntervalMs - elapsed; - scheduleCollection(c, building.id, remaining); return; } @@ -127,12 +125,6 @@ export const idleWorld = actor({ c.state.totalProduced += produced; building.lastCollectedAt = now; - scheduleCollection( - c, - building.id, - buildingType.productionIntervalMs, - ); - updateLeaderboard(c); broadcastState(c); }, @@ -146,12 +138,18 @@ export const idleWorld = actor({ }, }); -function scheduleCollection( +async function scheduleCollection( c: ActorContextOf, buildingId: string, - delayMs: number, -) { - c.schedule.after(delayMs, "collectProduction", { buildingId }); + intervalMs: number, +): Promise { + await c.cron.every({ + name: `collect-production:${buildingId}`, + intervalMs, + action: "collectProduction", + args: [{ buildingId }], + maxHistory: 0, + }); } function updateLeaderboard(c: ActorContextOf) { diff --git a/examples/scheduling/src/index.ts b/examples/scheduling/src/index.ts index 7a3e0b4d47..62bc84fdb8 100644 --- a/examples/scheduling/src/index.ts +++ b/examples/scheduling/src/index.ts @@ -2,6 +2,7 @@ import { actor, event, setup } from "rivetkit"; interface Reminder { id: string; + scheduleId: string; message: string; scheduledAt: number; completedAt?: number; @@ -23,29 +24,41 @@ const reminderActor = actor({ actions: { // Schedule a reminder with a delay in milliseconds - scheduleReminder: (c, message: string, delayMs: number) => { + scheduleReminder: async (c, message: string, delayMs: number) => { + const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const scheduleId = await c.schedule.after( + delayMs, + "triggerReminder", + id, + ); const reminder: Reminder = { - id: `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + id, + scheduleId, message, scheduledAt: Date.now() + delayMs, }; c.state.reminders.push(reminder); - c.schedule.after(delayMs, "triggerReminder", reminder.id); return reminder; }, // Schedule a reminder at a specific timestamp - scheduleReminderAt: (c, message: string, timestamp: number) => { + scheduleReminderAt: async (c, message: string, timestamp: number) => { + const id = `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const scheduleId = await c.schedule.at( + timestamp, + "triggerReminder", + id, + ); const reminder: Reminder = { - id: `reminder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + id, + scheduleId, message, scheduledAt: timestamp, }; c.state.reminders.push(reminder); - c.schedule.at(timestamp, "triggerReminder", reminder.id); return reminder; }, @@ -73,20 +86,19 @@ const reminderActor = actor({ return c.state.reminders; }, - // Cancel a scheduled reminder - // Note: Rivet doesn't currently support canceling scheduled actions - // This will only remove the reminder from state - cancelReminder: (c, reminderId: string) => { - // Remove from state - c.state.reminders = c.state.reminders.filter( - (r) => r.id !== reminderId, - ); + // Cancel a pending scheduled reminder. + cancelReminder: async (c, reminderId: string) => { + const reminder = c.state.reminders.find((r) => r.id === reminderId); + if (!reminder) return { success: false }; - return { - success: true, - message: - "reminder removed from state (note: scheduled action may still fire)", - }; + const cancelled = await c.schedule.cancel(reminder.scheduleId); + if (cancelled) { + c.state.reminders = c.state.reminders.filter( + (r) => r.id !== reminderId, + ); + } + + return { success: cancelled }; }, // Get statistics about reminders diff --git a/frontend/package.json b/frontend/package.json index d1949f4546..41e7fd12cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -123,6 +123,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "cronstrue": "^3.24.0", "d3-array": "^3.2.4", "date-fns": "^4.1.0", "es-toolkit": "^1.45.1", diff --git a/frontend/src/components/actors/actor-database.tsx b/frontend/src/components/actors/actor-database.tsx index 14e082b723..d57899962a 100644 --- a/frontend/src/components/actors/actor-database.tsx +++ b/frontend/src/components/actors/actor-database.tsx @@ -9,6 +9,7 @@ import { Icon, } from "@rivet-gg/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import _ from "lodash"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Badge, @@ -20,6 +21,7 @@ import { WithTooltip, } from "@/components"; import { ShimmerLine } from "../shimmer-line"; +import { formatValue } from "@/lib/format-value"; import { Select, SelectContent, @@ -787,7 +789,7 @@ function ActorDatabaseSqlShell({ actorId }: ActorDatabaseProps) { /> ) : (
-							{JSON.stringify(result.rows, null, 2)}
+							{formatValue(result.rows, true)}
 						
)} @@ -880,7 +882,7 @@ function createRowKey( if (values.some((value) => value === undefined)) { return null; } - return JSON.stringify(values); + return formatValue(values); } function createStagedEditId(rowKey: string, columnName: string): string { @@ -937,7 +939,7 @@ function areDatabaseValuesEqual(a: unknown, b: unknown): boolean { if (Object.is(a, b)) { return true; } - return JSON.stringify(a) === JSON.stringify(b); + return _.isEqual(a, b); } function quoteSqlIdentifier(value: string): string { diff --git a/frontend/src/components/actors/actor-details-shared.tsx b/frontend/src/components/actors/actor-details-shared.tsx index 2a7ca65242..e358b4cf2e 100644 --- a/frontend/src/components/actors/actor-details-shared.tsx +++ b/frontend/src/components/actors/actor-details-shared.tsx @@ -139,6 +139,7 @@ export const SKELETON_INSPECTOR_TABS: readonly InspectorTabDescriptor[] = [ { id: "database", label: "Database", icon: "database" }, { id: "state", label: "State", icon: "state" }, { id: "queue", label: "Queue", icon: "queue" }, + { id: "schedules", label: "Schedules", icon: "calendar" }, { id: "connections", label: "Connections", icon: "plug" }, { id: "console", label: "Console", icon: "terminal" }, ]; diff --git a/frontend/src/components/actors/actor-editable-state.tsx b/frontend/src/components/actors/actor-editable-state.tsx index 312ddf6768..831438fb6e 100644 --- a/frontend/src/components/actors/actor-editable-state.tsx +++ b/frontend/src/components/actors/actor-editable-state.tsx @@ -1,5 +1,6 @@ import { faRotateLeft, faSave, Icon } from "@rivet-gg/icons"; import { useMutation, useQuery } from "@tanstack/react-query"; +import { jsonParseCompat } from "rivetkit/utils"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo, useRef, useState } from "react"; import { @@ -17,11 +18,12 @@ import { import { useActorInspector } from "./actor-inspector-context"; import { ActorStateChangeIndicator } from "./actor-state-change-indicator"; import type { ActorId } from "./queries"; +import { formatValue } from "@/lib/format-value"; const isValidJson = (json: string | null): json is string => { if (!json) return false; try { - JSON.parse(json); + jsonParseCompat(json); return true; } catch { return false; @@ -42,9 +44,9 @@ export function ActorEditableState({ actorId }: ActorEditableStateProps) { const [value, setValue] = useState(null); const ref = useRef(null); const formatted = useMemo(() => { - return JSON.stringify(state, null, 2); + return formatValue(state, true); }, [state]); - const isValid = isValidJson(value) ? JSON.parse(value) : false; + const isValid = isValidJson(value) ? jsonParseCompat(value) : false; const { mutateAsync, isPending } = useMutation( actorInspector.actorStatePatchMutation(actorId), @@ -84,7 +86,7 @@ export function ActorEditableState({ actorId }: ActorEditableStateProps) { isLoading={isPending} disabled={!isValid || !isEditing} onClick={async () => { - await mutateAsync(JSON.parse(value || "")); + await mutateAsync(jsonParseCompat(value || "")); setIsEditing(false); setValue(null); }} diff --git a/frontend/src/components/actors/actor-inspector-context.tsx b/frontend/src/components/actors/actor-inspector-context.tsx index 2732b92fe5..5813043ab5 100644 --- a/frontend/src/components/actors/actor-inspector-context.tsx +++ b/frontend/src/components/actors/actor-inspector-context.tsx @@ -13,8 +13,11 @@ import { createContext, useContext, useMemo, useRef } from "react"; import type ReconnectingWebSocket from "reconnectingwebsocket"; import { type Connection, + CURRENT_VERSION as INSPECTOR_PROTOCOL_CURRENT_VERSION, decodeWorkflowHistoryTransport, type QueueStatus, + type Schedule, + type ScheduleFire, type ToServer, TO_CLIENT_VERSIONED as toClient, TO_SERVER_VERSIONED as toServer, @@ -50,6 +53,10 @@ export const actorInspectorQueriesKeys = { ["actor", actorId, "tab-config"] as const, actorInspectorInitialized: (actorId: ActorId) => ["actor", actorId, "inspector-initialized"] as const, + actorSchedules: (actorId: ActorId) => + ["actor", actorId, "schedules"] as const, + actorScheduleHistory: (actorId: ActorId, scheduleId: string) => + ["actor", actorId, "schedules", scheduleId, "history"] as const, }; type QueueStatusSummary = { @@ -107,6 +114,29 @@ export type InspectorTabConfigEntry = { hidden?: boolean; }; +export type InspectorSchedule = { + id: string; + name?: string; + kind: "at" | "cron" | "every"; + action: string; + args: unknown[]; + nextRunAt: number; + lastRunAt?: number; + expression?: string; + timezone?: string; + intervalMs?: number; + maxHistory?: number; +}; + +export type InspectorScheduleFire = { + action: string; + scheduledAt: number; + firedAt: number; + finishedAt?: number; + result: "running" | "ok" | "error" | "skipped"; + error?: { group: string; code: string; message: string; metadata?: unknown }; +}; + interface ActorInspectorApi { ping: () => Promise; executeAction: (name: string, args: unknown[]) => Promise; @@ -134,6 +164,15 @@ interface ActorInspectorApi { request: DatabaseExecuteRequest, ) => Promise; getMetadata: () => Promise<{ version: string }>; + getSchedules: () => Promise; + getScheduleHistory: ( + scheduleId: string, + limit: number, + ) => Promise; + deleteSchedule: ( + scheduleId: string, + kind: InspectorSchedule["kind"], + ) => Promise; } type FeatureSupport = { @@ -156,6 +195,8 @@ const MIN_RIVETKIT_VERSION_WORKFLOW_REPLAY = "2.1.6"; // (there is no `/inspector/tab-config` HTTP route anymore). Only negotiate v5 // with runtimes new enough to send it. const MIN_RIVETKIT_VERSION_TABCONFIG_INIT = "2.3.3"; +const MIN_RIVETKIT_VERSION_SCHEDULES = "2.3.4"; +const MIN_RIVETKIT_VERSION_INSPECTOR_NEGOTIATION = "2.3.4"; const INSPECTOR_ERROR_EVENTS_DROPPED = "inspector.events_dropped"; function parseSemver(version?: string) { @@ -230,12 +271,15 @@ function buildFeatureSupport( }; } -function getInspectorProtocolVersion(version: string | undefined) { +export function getInspectorProtocolVersion(version: string | undefined) { const parsed = parseSemver(version); if (!parsed) { return 2; } if (isVersionAtLeast(version, MIN_RIVETKIT_VERSION_DATABASE)) { + if (isVersionAtLeast(version, MIN_RIVETKIT_VERSION_SCHEDULES)) { + return 6; + } if (isVersionAtLeast(version, MIN_RIVETKIT_VERSION_TABCONFIG_INIT)) { return 5; } @@ -250,6 +294,68 @@ function getInspectorProtocolVersion(version: string | undefined) { return 1; } +export function usesNegotiatedInspectorProtocol( + version: string | undefined, +): boolean { + return isVersionAtLeast( + version, + MIN_RIVETKIT_VERSION_INSPECTOR_NEGOTIATION, + ); +} + +export function buildInspectorWebSocketUrl( + baseUrl: string, + version: number, + negotiated: boolean, +): string { + return `${baseUrl}/inspector/connect${ + negotiated ? `?protocol_version=${version}` : "" + }`; +} + +function normalizeSchedules(schedules: readonly Schedule[]): InspectorSchedule[] { + return schedules.map((schedule) => ({ + id: schedule.id, + name: schedule.name ?? undefined, + kind: schedule.kind as InspectorSchedule["kind"], + action: schedule.action, + args: cbor.decode(new Uint8Array(schedule.args)) as unknown[], + nextRunAt: Number(schedule.nextRunAt), + lastRunAt: + schedule.lastRunAt == null ? undefined : Number(schedule.lastRunAt), + expression: schedule.expression ?? undefined, + timezone: schedule.timezone ?? undefined, + intervalMs: + schedule.intervalMs == null ? undefined : Number(schedule.intervalMs), + maxHistory: + schedule.maxHistory == null ? undefined : Number(schedule.maxHistory), + })); +} + +function normalizeScheduleHistory( + history: readonly ScheduleFire[], +): InspectorScheduleFire[] { + return history.map((fire) => ({ + action: fire.action, + scheduledAt: Number(fire.scheduledAt), + firedAt: Number(fire.firedAt), + finishedAt: + fire.finishedAt == null ? undefined : Number(fire.finishedAt), + result: fire.result as InspectorScheduleFire["result"], + error: fire.error + ? { + group: fire.error.group, + code: fire.error.code, + message: fire.error.message, + metadata: + fire.error.metadata == null + ? undefined + : cbor.decode(new Uint8Array(fire.error.metadata)), + } + : undefined, + })); +} + function normalizeQueueStatus(status: QueueStatus): QueueStatusSummary { return { size: Number(status.size), @@ -467,6 +573,42 @@ export const createDefaultActorInspectorContext = ({ }); }, + actorSchedulesQueryOptions(actorId: ActorId) { + return queryOptions({ + staleTime: Infinity, + queryKey: actorInspectorQueriesKeys.actorSchedules(actorId), + queryFn: () => api.getSchedules(), + }); + }, + + actorScheduleHistoryQueryOptions( + actorId: ActorId, + scheduleId: string, + limit = 25, + ) { + return queryOptions({ + staleTime: Infinity, + queryKey: actorInspectorQueriesKeys.actorScheduleHistory( + actorId, + scheduleId, + ), + queryFn: () => api.getScheduleHistory(scheduleId, limit), + }); + }, + + actorScheduleDeleteMutation(actorId: ActorId) { + return mutationOptions({ + mutationKey: ["actor", actorId, "schedules", "delete"], + mutationFn: ({ + scheduleId, + kind, + }: { + scheduleId: string; + kind: InspectorSchedule["kind"]; + }) => api.deleteSchedule(scheduleId, kind), + }); + }, + actorWorkflowReplayMutation(actorId: ActorId) { return mutationOptions({ mutationKey: ["actor", actorId, "workflow", "replay"], @@ -659,6 +801,7 @@ export type ActorInspectorContext = ReturnType< features: { traces: FeatureSupport; queue: FeatureSupport; + schedules: FeatureSupport; }; }; @@ -725,9 +868,14 @@ export const ActorInspectorProvider = ({ const isInspectorAvailable = isActorMetadataSuccess; const rivetkitVersion = actorMetadata?.version; const inspectorProtocolVersion = useMemo( - () => getInspectorProtocolVersion(rivetkitVersion), + () => + Math.min( + getInspectorProtocolVersion(rivetkitVersion), + INSPECTOR_PROTOCOL_CURRENT_VERSION, + ), [rivetkitVersion], ); + const negotiatedInspectorProtocol = usesNegotiatedInspectorProtocol(rivetkitVersion); const features = useMemo( () => ({ traces: buildFeatureSupport( @@ -740,16 +888,38 @@ export const ActorInspectorProvider = ({ MIN_RIVETKIT_VERSION_QUEUE, "Queue", ), + schedules: buildFeatureSupport( + rivetkitVersion, + MIN_RIVETKIT_VERSION_SCHEDULES, + "Schedules", + ), }), [rivetkitVersion], ); const onMessage = useMemo(() => { - return createMessageHandler({ queryClient, actorId, actionsManager }); - }, [queryClient, actorId]); + return createMessageHandler({ + queryClient, + actorId, + actionsManager, + version: inspectorProtocolVersion, + negotiated: negotiatedInspectorProtocol, + }); + }, [ + queryClient, + actorId, + inspectorProtocolVersion, + negotiatedInspectorProtocol, + ]); + + const inspectorUrl = buildInspectorWebSocketUrl( + computeActorUrl({ ...credentials, actorId }), + inspectorProtocolVersion, + negotiatedInspectorProtocol, + ); const { sendMessage, reconnect, status } = useWebSocket( - `${computeActorUrl({ ...credentials, actorId })}/inspector/connect`, + inspectorUrl, protocols, { onMessage, enabled: isInspectorAvailable }, ); @@ -783,6 +953,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -802,6 +973,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); }, @@ -820,6 +992,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -841,6 +1014,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -861,6 +1035,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -887,6 +1062,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -912,6 +1088,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -936,6 +1113,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -970,6 +1148,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -998,6 +1177,7 @@ export const ActorInspectorProvider = ({ }, }, inspectorProtocolVersion, + negotiatedInspectorProtocol, ), ); @@ -1050,6 +1230,69 @@ export const ActorInspectorProvider = ({ .parse(payload); }, + getSchedules: async () => { + const { id, promise } = actionsManager.current.createResolver< + InspectorSchedule[] + >({ name: "getSchedules", timeoutMs: 10_000 }); + sendMessage( + serverMessage( + { + body: { + tag: "SchedulesRequest", + val: { id: BigInt(id) }, + }, + }, + inspectorProtocolVersion, + negotiatedInspectorProtocol, + ), + ); + return promise; + }, + + getScheduleHistory: async (scheduleId, limit) => { + const { id, promise } = actionsManager.current.createResolver< + InspectorScheduleFire[] + >({ name: "getScheduleHistory", timeoutMs: 10_000 }); + sendMessage( + serverMessage( + { + body: { + tag: "ScheduleHistoryRequest", + val: { + id: BigInt(id), + scheduleId, + limit: BigInt(Math.max(1, Math.floor(limit))), + }, + }, + }, + inspectorProtocolVersion, + negotiatedInspectorProtocol, + ), + ); + return promise; + }, + + deleteSchedule: async (scheduleId, kind) => { + const { id, promise } = + actionsManager.current.createResolver({ + name: "deleteSchedule", + timeoutMs: 10_000, + }); + sendMessage( + serverMessage( + { + body: { + tag: "ScheduleDeleteRequest", + val: { id: BigInt(id), scheduleId, kind }, + }, + }, + inspectorProtocolVersion, + negotiatedInspectorProtocol, + ), + ); + return promise; + }, + getMetadata() { return getActorMetadataProxy.current(); }, @@ -1058,6 +1301,7 @@ export const ActorInspectorProvider = ({ sendMessage, reconnect, inspectorProtocolVersion, + negotiatedInspectorProtocol, actorId, credentials, ]); @@ -1094,17 +1338,22 @@ const createMessageHandler = queryClient, actorId, actionsManager, + version, + negotiated, }: { queryClient: QueryClient; actorId: ActorId; actionsManager: React.RefObject; + version: number; + negotiated: boolean; }) => async (e: ReconnectingWebSocket.MessageEvent) => { - let message: ReturnType; + let message: ReturnType; try { - message = toClient.deserializeWithEmbeddedVersion( - new Uint8Array(await e.data.arrayBuffer()), - ); + const bytes = new Uint8Array(await e.data.arrayBuffer()); + message = negotiated + ? toClient.deserialize(bytes, version) + : toClient.deserializeWithEmbeddedVersion(bytes); } catch (error) { console.warn("Failed to decode inspector message", error); return; @@ -1112,6 +1361,10 @@ const createMessageHandler = match(message.body) .with({ tag: "Init" }, (body) => { + queryClient.setQueryData( + actorInspectorQueriesKeys.actorSchedules(actorId), + normalizeSchedules(body.val.schedules ?? []), + ); queryClient.setQueryData( actorInspectorQueriesKeys.actorState(actorId), !body.val.isStateEnabled || body.val.state == null @@ -1296,6 +1549,34 @@ const createMessageHandler = cbor.decode(new Uint8Array(body.val.result)), ); }) + .with({ tag: "SchedulesResponse" }, (body) => { + actionsManager.current.resolve( + Number(body.val.rid), + normalizeSchedules(body.val.schedules), + ); + }) + .with({ tag: "SchedulesUpdated" }, (body) => { + queryClient.setQueryData( + actorInspectorQueriesKeys.actorSchedules(actorId), + normalizeSchedules(body.val.schedules), + ); + queryClient.invalidateQueries({ + queryKey: actorInspectorQueriesKeys.actorSchedules(actorId), + predicate: (query) => query.queryKey.includes("history"), + }); + }) + .with({ tag: "ScheduleHistoryResponse" }, (body) => { + actionsManager.current.resolve( + Number(body.val.rid), + normalizeScheduleHistory(body.val.history), + ); + }) + .with({ tag: "ScheduleDeleteResponse" }, (body) => { + actionsManager.current.resolve( + Number(body.val.rid), + body.val.deleted, + ); + }) .with({ tag: "Error" }, (body) => { if (body.val.message === INSPECTOR_ERROR_EVENTS_DROPPED) { return; @@ -1332,8 +1613,14 @@ function transformWorkflowHistoryFromInspector(raw: ArrayBuffer): { } } -function serverMessage(data: ToServer, version: number) { - return toServer.serializeWithEmbeddedVersion(data, version); +export function serverMessage( + data: ToServer, + version: number, + negotiated: boolean, +) { + return negotiated + ? toServer.serialize(data, version) + : toServer.serializeWithEmbeddedVersion(data, version); } class ActionsManager { diff --git a/frontend/src/components/actors/actor-inspector-protocol.test.ts b/frontend/src/components/actors/actor-inspector-protocol.test.ts new file mode 100644 index 0000000000..f690d6f22c --- /dev/null +++ b/frontend/src/components/actors/actor-inspector-protocol.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { + TO_SERVER_VERSIONED as toServer, + type ToServer, +} from "rivetkit/inspector/client"; +import { + buildInspectorWebSocketUrl, + getInspectorProtocolVersion, + serverMessage, + usesNegotiatedInspectorProtocol, +} from "./actor-inspector-context"; + +vi.mock("reconnectingwebsocket", () => ({ default: class {} })); + +const request: ToServer = { + body: { tag: "StateRequest", val: { id: 1n } }, +}; + +describe("inspector protocol negotiation", () => { + it("uses query-parameter negotiation for supporting actors", () => { + const version = getInspectorProtocolVersion("2.3.4"); + expect(version).toBe(6); + expect(usesNegotiatedInspectorProtocol("2.3.4")).toBe(true); + expect(buildInspectorWebSocketUrl("https://actor", version, true)).toBe( + "https://actor/inspector/connect?protocol_version=6", + ); + }); + + it("retains embedded framing for older actors", () => { + const version = getInspectorProtocolVersion("2.3.3"); + expect(version).toBe(5); + expect(usesNegotiatedInspectorProtocol("2.3.3")).toBe(false); + expect(buildInspectorWebSocketUrl("https://actor", version, false)).toBe( + "https://actor/inspector/connect", + ); + }); + + it("serializes negotiated messages without an embedded version", () => { + const bytes = serverMessage(request, 6, true); + expect(toServer.deserialize(bytes, 6).body.tag).toBe("StateRequest"); + expect(() => toServer.deserializeWithEmbeddedVersion(bytes)).toThrow(); + }); + + it("serializes legacy messages with embedded v5 framing", () => { + const bytes = serverMessage(request, 5, false); + expect(toServer.deserializeWithEmbeddedVersion(bytes).body.tag).toBe( + "StateRequest", + ); + }); +}); diff --git a/frontend/src/components/actors/actor-schedules-format.ts b/frontend/src/components/actors/actor-schedules-format.ts new file mode 100644 index 0000000000..bd75225802 --- /dev/null +++ b/frontend/src/components/actors/actor-schedules-format.ts @@ -0,0 +1,39 @@ +import cronstrue from "cronstrue"; +import type { InspectorSchedule } from "./actor-inspector-context"; + +export function formatSchedule(schedule: InspectorSchedule): string { + if (schedule.kind === "at") { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(schedule.nextRunAt); + } + if (schedule.kind === "every") { + return `Every ${formatDuration(schedule.intervalMs ?? 0)}`; + } + return schedule.expression ?? "Cron"; +} + +export function describeCronExpression(expression: string): string { + try { + return cronstrue.toString(expression, { + verbose: true, + trimHoursLeadingZero: true, + }); + } catch { + return "Unable to describe this cron expression"; + } +} + +export function formatDuration(durationMs: number): string { + const ms = Math.max(0, durationMs); + if (ms < 1_000) return `${ms} ms`; + if (ms < 60_000) return `${trimDecimal(ms / 1_000)} seconds`; + if (ms < 3_600_000) return `${trimDecimal(ms / 60_000)} minutes`; + if (ms < 86_400_000) return `${trimDecimal(ms / 3_600_000)} hours`; + return `${trimDecimal(ms / 86_400_000)} days`; +} + +function trimDecimal(value: number): string { + return Number.isInteger(value) ? value.toString() : value.toFixed(1); +} diff --git a/frontend/src/components/actors/actor-schedules-tab.test.ts b/frontend/src/components/actors/actor-schedules-tab.test.ts new file mode 100644 index 0000000000..db90ddeecf --- /dev/null +++ b/frontend/src/components/actors/actor-schedules-tab.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; +import type { InspectorSchedule } from "./actor-inspector-context"; +import { + describeCronExpression, + formatDuration, + formatSchedule, +} from "./actor-schedules-format"; + +const baseSchedule: InspectorSchedule = { + id: "refresh-cache", + name: "refresh-cache", + kind: "every", + action: "refresh", + args: [], + nextRunAt: 1_700_000_000_000, +}; + +describe("schedule inspector formatting", () => { + test("formats one-time, interval, and cron schedules", () => { + const oneTime = { ...baseSchedule, kind: "at" as const, name: undefined }; + expect(formatSchedule(oneTime)).toBe( + new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(oneTime.nextRunAt), + ); + expect( + formatSchedule({ ...baseSchedule, intervalMs: 5 * 60_000 }), + ).toBe("Every 5 minutes"); + expect( + formatSchedule({ + ...baseSchedule, + kind: "cron", + expression: "0 9 * * *", + timezone: "America/Los_Angeles", + }), + ).toBe("0 9 * * *"); + expect(describeCronExpression("0 9 * * *")).toBe( + "At 9:00 AM, every day", + ); + expect(describeCronExpression("not a cron")).toBe( + "Unable to describe this cron expression", + ); + }); + + test("formats useful sub-second through multi-day durations", () => { + expect(formatDuration(124)).toBe("124 ms"); + expect(formatDuration(5_000)).toBe("5 seconds"); + expect(formatDuration(90_000)).toBe("1.5 minutes"); + expect(formatDuration(7_200_000)).toBe("2 hours"); + expect(formatDuration(172_800_000)).toBe("2 days"); + }); +}); diff --git a/frontend/src/components/actors/actor-schedules-tab.tsx b/frontend/src/components/actors/actor-schedules-tab.tsx new file mode 100644 index 0000000000..257fee8da8 --- /dev/null +++ b/frontend/src/components/actors/actor-schedules-tab.tsx @@ -0,0 +1,396 @@ +import { faCalendar, faSpinnerThird, faTrash, Icon } from "@rivet-gg/icons"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Fragment, type ReactNode, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { WithTooltip } from "@/components"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "../lib/utils"; +import { formatValue } from "@/lib/format-value"; +import { + type InspectorSchedule, + type InspectorScheduleFire, + useActorInspector, +} from "./actor-inspector-context"; +import { + describeCronExpression, + formatDuration, + formatSchedule, +} from "./actor-schedules-format"; +import type { ActorId } from "./queries"; + +export function ActorSchedulesTab({ actorId }: { actorId: ActorId }) { + const inspector = useActorInspector(); + const [selectedId, setSelectedId] = useState(); + const [now, setNow] = useState(() => Date.now()); + + const { data: schedules = [], isLoading } = useQuery( + inspector.actorSchedulesQueryOptions(actorId), + ); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(timer); + }, []); + + if (!inspector.features.schedules.supported) { + return ( +
+ {inspector.features.schedules.message} +
+ ); + } + + return ( +
+ {isLoading ? ( +
+ + Loading schedules… +
+ ) : schedules.length === 0 ? ( + + ) : ( + + + + Name / ID + Action + Schedule + Next run + Last run + + + + {schedules.map((schedule) => { + const isSelected = schedule.id === selectedId; + const toggle = () => + setSelectedId(isSelected ? undefined : schedule.id); + + return ( + + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(); + } + }} + data-testid={`schedule-row-${schedule.id}`} + > + + + + + {schedule.action} + + + + + + + + + {schedule.lastRunAt ? ( + + ) : ( + "—" + )} + + + {isSelected && ( + + + setSelectedId(undefined)} + /> + + + )} + + ); + })} + +
+ )} +
+ ); +} + +function EmptySchedules() { + return ( +
+
+ +
+

No schedules yet

+

+ Pending one-time and recurring schedules will appear here. +

+
+ ); +} + +function ScheduleName({ schedule }: { schedule: InspectorSchedule }) { + if (schedule.name) { + return
{schedule.name}
; + } + + const shortId = + schedule.id.length > 16 + ? `${schedule.id.slice(0, 8)}…${schedule.id.slice(-4)}` + : schedule.id; + return ( + + {shortId} + + } + /> + ); +} + +function ScheduleValue({ schedule }: { schedule: InspectorSchedule }) { + if (schedule.kind !== "cron") { + return {formatSchedule(schedule)}; + } + + const expression = schedule.expression ?? ""; + return ( + +
{describeCronExpression(expression)}
+
+ Timezone: {schedule.timezone ?? "UTC"} +
+ + } + trigger={ + + {expression} + + } + /> + ); +} + +function ScheduleDetails({ + actorId, + schedule, + now, + onClose, +}: { + actorId: ActorId; + schedule: InspectorSchedule; + now: number; + onClose: () => void; +}) { + const inspector = useActorInspector(); + const [confirming, setConfirming] = useState(false); + const isRecurring = schedule.kind !== "at"; + const { data: history = [], isLoading } = useQuery({ + ...inspector.actorScheduleHistoryQueryOptions(actorId, schedule.id), + enabled: isRecurring, + }); + const deletion = useMutation({ + ...inspector.actorScheduleDeleteMutation(actorId), + onSuccess: (deleted) => { + if (deleted) toast.success("Schedule deleted"); + onClose(); + }, + onError: (error) => toast.error(error.message), + }); + + return ( +
+
+
+ + {schedule.id} + + +
{formatTimestamp(schedule.nextRunAt)}
+
+ +
+
+ {schedule.lastRunAt && ( + + {formatTimestamp(schedule.lastRunAt)} + + )} + {schedule.maxHistory != null && ( + + {schedule.maxHistory === 0 + ? "Disabled" + : `Keep ${schedule.maxHistory} entries`} + + )} +
+ +
+

+ Arguments +

+
+						{formatValue(schedule.args, true)}
+					
+
+
+ + {isRecurring && ( +
+

+ Recent runs +

+ {isLoading ? ( +
+ + Loading history… +
+ ) : history.length ? ( + + ) : ( +
+ No runs recorded yet. +
+ )} +
+ )} + +
+ +
+
+ ); +} + +function Detail({ label, children }: { label: string; children: ReactNode }) { + return ( + <> +
{label}
+
{children}
+ + ); +} + +function HistoryList({ + history, + now, +}: { + history: InspectorScheduleFire[]; + now: number; +}) { + return ( +
+ {history.map((fire, index) => ( +
+ +
+
{formatTimestamp(fire.firedAt)}
+ {fire.error && ( +
+ {fire.error.code}: {fire.error.message} +
+ )} +
+
+ {formatDuration((fire.finishedAt ?? now) - fire.firedAt)} +
+
+ ))} +
+ ); +} + +function ResultBadge({ result }: { result: InspectorScheduleFire["result"] }) { + return ( +
+ + {result === "ok" ? "Success" : result} +
+ ); +} + +function RelativeTime({ timestamp, now }: { timestamp: number; now: number }) { + const delta = timestamp - now; + const future = delta >= 0; + const absolute = Math.abs(delta); + let value: string; + if (absolute < 60_000) value = `${Math.max(1, Math.round(absolute / 1_000))}s`; + else if (absolute < 3_600_000) value = `${Math.round(absolute / 60_000)}m`; + else if (absolute < 86_400_000) value = `${Math.round(absolute / 3_600_000)}h`; + else value = `${Math.round(absolute / 86_400_000)}d`; + return ( + + ); +} + +function formatTimestamp(timestamp: number): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }).format(timestamp); +} diff --git a/frontend/src/components/actors/actor-status-label.tsx b/frontend/src/components/actors/actor-status-label.tsx index 72e1016322..7ce47442c2 100644 --- a/frontend/src/components/actors/actor-status-label.tsx +++ b/frontend/src/components/actors/actor-status-label.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { formatISO } from "date-fns"; import { isObject } from "lodash"; +import { formatValue } from "@/lib/format-value"; import { match, P } from "ts-pattern"; import type { RivetActorError } from "@/queries/types"; import { CodePreview } from "../code-preview/code-preview"; @@ -283,7 +284,7 @@ export function ErrorDetailsContent({ error }: { error: unknown }) { diff --git a/frontend/src/components/actors/console/actor-console-log.tsx b/frontend/src/components/actors/console/actor-console-log.tsx index 38b97eb0cf..f40f87296e 100644 --- a/frontend/src/components/actors/console/actor-console-log.tsx +++ b/frontend/src/components/actors/console/actor-console-log.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { formatValue } from "@/lib/format-value"; import type { ReplCommand } from "../worker/actor-worker-container"; import { ActorConsoleLogFormatted } from "./actor-console-log-formatted"; import { ActorConsoleMessage } from "./actor-console-message"; @@ -36,7 +37,7 @@ export const ActorConsoleLog = memo((props: ActorConsoleLogProps) => { typeof props.error === "object" && "toString" in props.error ? props.error.toString() - : JSON.stringify(props.error)} + : formatValue(props.error)} ) : null} {props.logs?.map((log, index) => ( diff --git a/frontend/src/components/actors/inspector-tab-icons.tsx b/frontend/src/components/actors/inspector-tab-icons.tsx index f518786991..ee2e524bbf 100644 --- a/frontend/src/components/actors/inspector-tab-icons.tsx +++ b/frontend/src/components/actors/inspector-tab-icons.tsx @@ -1,5 +1,6 @@ import { faBoxArchive, + faCalendar, faComments, faCubesStacked, faDatabase, @@ -31,6 +32,7 @@ const ICON_REGISTRY: Record = { database: faDatabase, state: faCubesStacked, queue: faInbox, + calendar: faCalendar, plug: faPlug, terminal: faTerminal, tag: faTag, diff --git a/frontend/src/components/actors/inspector-tab-registry.tsx b/frontend/src/components/actors/inspector-tab-registry.tsx index 3810715a66..e314dc34ce 100644 --- a/frontend/src/components/actors/inspector-tab-registry.tsx +++ b/frontend/src/components/actors/inspector-tab-registry.tsx @@ -23,6 +23,11 @@ const ActorStateTab = lazy(() => const ActorQueueTab = lazy(() => import("./actor-queue-tab").then((m) => ({ default: m.ActorQueueTab })), ); +const ActorSchedulesTab = lazy(() => + import("./actor-schedules-tab").then((m) => ({ + default: m.ActorSchedulesTab, + })), +); const ActorConnectionsTab = lazy(() => import("./actor-connections-tab").then((m) => ({ default: m.ActorConnectionsTab, @@ -61,6 +66,7 @@ type ActorCapabilities = { isDatabaseEnabled: boolean; isStateEnabled: boolean; isQueueSupported: boolean; + isSchedulesSupported: boolean; }; interface TabRegistration { @@ -93,6 +99,15 @@ export const INSPECTOR_TAB_REGISTRATIONS: readonly TabRegistration[] = [ available: (caps) => caps.isQueueSupported, render: (actorId) => , }, + { + descriptor: { + id: "schedules", + label: "Schedules", + icon: "calendar", + }, + available: (caps) => caps.isSchedulesSupported, + render: (actorId) => , + }, { descriptor: { id: "connections", label: "Connections", icon: "plug" }, available: () => true, @@ -133,6 +148,7 @@ export function useAvailableInspectorTabs( ); const isStateEnabled = stateData?.isEnabled ?? false; const isQueueSupported = inspector.features.queue.supported; + const isSchedulesSupported = inspector.features.schedules.supported; // Tab config arrives with the WS `Init` message (same tick as the // capability flags below), so the empty default only shows before the @@ -156,6 +172,7 @@ export function useAvailableInspectorTabs( isDatabaseEnabled, isStateEnabled, isQueueSupported, + isSchedulesSupported, }; const hideSet = new Set( (tabConfig?.tabs ?? []) @@ -189,6 +206,7 @@ export function useAvailableInspectorTabs( isDatabaseEnabled, isStateEnabled, isQueueSupported, + isSchedulesSupported, tabConfig, ]); } diff --git a/frontend/src/components/json/index.tsx b/frontend/src/components/json/index.tsx index cf36ad7307..d7a82bf98b 100644 --- a/frontend/src/components/json/index.tsx +++ b/frontend/src/components/json/index.tsx @@ -21,6 +21,7 @@ import { useState, } from "react"; import { CopyTrigger } from "../copy-area"; +import { formatValue } from "@/lib/format-value"; import { cn } from "../lib/utils"; import { Checkbox } from "../ui/checkbox"; import { WithTooltip } from "../ui/tooltip"; @@ -114,6 +115,9 @@ function Value({ if (value === undefined) { return ; } + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + return {formatValue(value)}; + } if (isObject(value)) { const Comp = object ?? ObjectValue; return ; @@ -122,6 +126,7 @@ function Value({ const Comp = array ?? ObjectValue; return ; } + return {formatValue(value)}; } function isObject(value: unknown): value is Record { diff --git a/frontend/src/lib/format-value.test.ts b/frontend/src/lib/format-value.test.ts new file mode 100644 index 0000000000..330d404521 --- /dev/null +++ b/frontend/src/lib/format-value.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { formatValue } from "./format-value"; + +describe("formatValue", () => { + it("pretty-prints bigint and byte buffers", () => { + const formatted = formatValue( + { + count: 42n, + bytes: new Uint8Array([1, 2, 3]), + buffer: new Uint8Array([4, 5]).buffer, + }, + true, + ); + expect(formatted).toContain('"$BigInt"'); + expect(formatted).toContain('"$Uint8Array"'); + expect(formatted).toContain('"$ArrayBuffer"'); + expect(formatted).toContain("\n"); + }); + + it("renders unsupported root values deterministically", () => { + expect(formatValue(undefined)).toBe("undefined"); + expect(formatValue(Symbol("test"))).toBe("Symbol(test)"); + expect(formatValue(function example() {})).toBe("[Function example]"); + }); + + it("does not throw for circular values", () => { + const circular: Record = {}; + circular.self = circular; + expect(formatValue(circular)).toBe("[Unserializable value]"); + }); +}); diff --git a/frontend/src/lib/format-value.ts b/frontend/src/lib/format-value.ts new file mode 100644 index 0000000000..4d8ea3c69b --- /dev/null +++ b/frontend/src/lib/format-value.ts @@ -0,0 +1,19 @@ +import { jsonStringifyCompat } from "rivetkit/utils"; + +export function formatValue(value: unknown, pretty = false): string { + try { + const formatted = jsonStringifyCompat(value, pretty ? 2 : undefined); + if (typeof formatted === "string") { + return formatted; + } + } catch { + // Fall through to a stable render-safe description. + } + + if (value === undefined) return "undefined"; + if (typeof value === "function") { + return value.name ? `[Function ${value.name}]` : "[Function]"; + } + if (typeof value === "symbol") return String(value); + return "[Unserializable value]"; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3550ec594..4a1a856cb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2618,6 +2618,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + cronstrue: + specifier: ^3.24.0 + version: 3.24.0 d3-array: specifier: ^3.2.4 version: 3.2.4 @@ -11044,6 +11047,10 @@ packages: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} + cronstrue@3.24.0: + resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==} + hasBin: true + cross-fetch@4.1.0: resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} @@ -26902,6 +26909,8 @@ snapshots: croner@10.0.1: {} + cronstrue@3.24.0: {} + cross-fetch@4.1.0: dependencies: node-fetch: 2.7.0 diff --git a/rivetkit-rust/engine/artifacts/errors/schedule.interrupted.json b/rivetkit-rust/engine/artifacts/errors/schedule.interrupted.json new file mode 100644 index 0000000000..f4e519364a --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/schedule.interrupted.json @@ -0,0 +1,5 @@ +{ + "code": "interrupted", + "group": "schedule", + "message": "Scheduled action was interrupted." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/schedule.invalid_cron_expression.json b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_cron_expression.json new file mode 100644 index 0000000000..0209df5f87 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_cron_expression.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_cron_expression", + "group": "schedule", + "message": "Cron expression is invalid." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/schedule.invalid_interval.json b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_interval.json new file mode 100644 index 0000000000..9a25dacf38 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_interval.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_interval", + "group": "schedule", + "message": "Schedule interval is invalid." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/schedule.invalid_max_history.json b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_max_history.json new file mode 100644 index 0000000000..8211d327d1 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_max_history.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_max_history", + "group": "schedule", + "message": "Schedule history limit is invalid." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/schedule.invalid_name.json b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_name.json new file mode 100644 index 0000000000..e4cc8975b7 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_name.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_name", + "group": "schedule", + "message": "Schedule name is invalid." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/schedule.invalid_schedule_row.json b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_schedule_row.json new file mode 100644 index 0000000000..a4634f7e90 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_schedule_row.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_schedule_row", + "group": "schedule", + "message": "Stored schedule data is invalid." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/schedule.invalid_timezone.json b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_timezone.json new file mode 100644 index 0000000000..4c1b5eace5 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/schedule.invalid_timezone.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_timezone", + "group": "schedule", + "message": "Schedule timezone is invalid." +} \ No newline at end of file diff --git a/rivetkit-rust/engine/artifacts/errors/schedule.max_schedules_exceeded.json b/rivetkit-rust/engine/artifacts/errors/schedule.max_schedules_exceeded.json new file mode 100644 index 0000000000..aab4ec3ac0 --- /dev/null +++ b/rivetkit-rust/engine/artifacts/errors/schedule.max_schedules_exceeded.json @@ -0,0 +1,5 @@ +{ + "code": "max_schedules_exceeded", + "group": "schedule", + "message": "Actor schedule limit reached." +} \ No newline at end of file diff --git a/rivetkit-rust/packages/inspector-protocol/schemas/v6.bare b/rivetkit-rust/packages/inspector-protocol/schemas/v6.bare new file mode 100644 index 0000000000..894c943d91 --- /dev/null +++ b/rivetkit-rust/packages/inspector-protocol/schemas/v6.bare @@ -0,0 +1,286 @@ +# Actor Inspector BARE Schema v6 + +type State data +type WorkflowHistory data + +type PatchStateRequest struct { + state: data +} + +type ActionRequest struct { + id: uint + name: str + args: data +} + +type StateRequest struct { + id: uint +} + +type ConnectionsRequest struct { + id: uint +} + +type RpcsListRequest struct { + id: uint +} + +type TraceQueryRequest struct { + id: uint + startMs: uint + endMs: uint + limit: uint +} + +type QueueRequest struct { + id: uint + limit: uint +} + +type WorkflowHistoryRequest struct { + id: uint +} + +type WorkflowReplayRequest struct { + id: uint + entryId: optional +} + +type DatabaseSchemaRequest struct { + id: uint +} + +type DatabaseTableRowsRequest struct { + id: uint + table: str + limit: uint + offset: uint +} + +type SchedulesRequest struct { + id: uint +} + +type ScheduleHistoryRequest struct { + id: uint + scheduleId: str + limit: uint +} + +type ScheduleDeleteRequest struct { + id: uint + scheduleId: str + kind: str +} + +type ToServerBody union { + PatchStateRequest | + StateRequest | + ConnectionsRequest | + ActionRequest | + RpcsListRequest | + TraceQueryRequest | + QueueRequest | + WorkflowHistoryRequest | + WorkflowReplayRequest | + DatabaseSchemaRequest | + DatabaseTableRowsRequest | + SchedulesRequest | + ScheduleHistoryRequest | + ScheduleDeleteRequest +} + +type ToServer struct { + body: ToServerBody +} + +type Connection struct { + id: str + details: data +} + +# Inspector tab descriptor. `label`/`icon` are set for author-declared custom +# tabs; a hidden built-in only carries `id` with `hidden` set. +type TabConfigEntry struct { + id: str + label: optional + icon: optional + hidden: bool +} + +type Schedule struct { + id: str + name: optional + kind: str + action: str + args: data + nextRunAt: uint + lastRunAt: optional + expression: optional + timezone: optional + intervalMs: optional + maxHistory: optional +} + +type ScheduleError struct { + group: str + code: str + message: str + metadata: optional +} + +type ScheduleFire struct { + action: str + scheduledAt: uint + firedAt: uint + finishedAt: optional + result: str + error: optional +} + +type Init struct { + connections: list + state: optional + isStateEnabled: bool + rpcs: list + isDatabaseEnabled: bool + queueSize: uint + workflowHistory: optional + isWorkflowEnabled: bool + tabConfig: list + schedules: list +} + +type ConnectionsResponse struct { + rid: uint + connections: list +} + +type StateResponse struct { + rid: uint + state: optional + isStateEnabled: bool +} + +type ActionResponse struct { + rid: uint + output: data +} + +type TraceQueryResponse struct { + rid: uint + payload: data +} + +type QueueMessageSummary struct { + id: uint + name: str + createdAtMs: uint +} + +type QueueStatus struct { + size: uint + maxSize: uint + messages: list + truncated: bool +} + +type QueueResponse struct { + rid: uint + status: QueueStatus +} + +type WorkflowHistoryResponse struct { + rid: uint + history: optional + isWorkflowEnabled: bool +} + +type WorkflowReplayResponse struct { + rid: uint + history: optional + isWorkflowEnabled: bool +} + +type DatabaseSchemaResponse struct { + rid: uint + schema: data +} + +type DatabaseTableRowsResponse struct { + rid: uint + result: data +} + +type SchedulesResponse struct { + rid: uint + schedules: list +} + +type ScheduleHistoryResponse struct { + rid: uint + scheduleId: str + history: list +} + +type ScheduleDeleteResponse struct { + rid: uint + scheduleId: str + deleted: bool +} + +type StateUpdated struct { + state: State +} + +type QueueUpdated struct { + queueSize: uint +} + +type WorkflowHistoryUpdated struct { + history: WorkflowHistory +} + +type SchedulesUpdated struct { + schedules: list +} + +type RpcsListResponse struct { + rid: uint + rpcs: list +} + +type ConnectionsUpdated struct { + connections: list +} + +type Error struct { + message: str +} + +type ToClientBody union { + StateResponse | + ConnectionsResponse | + ActionResponse | + ConnectionsUpdated | + QueueUpdated | + StateUpdated | + WorkflowHistoryUpdated | + SchedulesUpdated | + RpcsListResponse | + TraceQueryResponse | + QueueResponse | + WorkflowHistoryResponse | + WorkflowReplayResponse | + SchedulesResponse | + ScheduleHistoryResponse | + ScheduleDeleteResponse | + Error | + Init | + DatabaseSchemaResponse | + DatabaseTableRowsResponse +} + +type ToClient struct { + body: ToClientBody +} diff --git a/rivetkit-rust/packages/inspector-protocol/src/lib.rs b/rivetkit-rust/packages/inspector-protocol/src/lib.rs index 5074a9cf35..47a249dbc3 100644 --- a/rivetkit-rust/packages/inspector-protocol/src/lib.rs +++ b/rivetkit-rust/packages/inspector-protocol/src/lib.rs @@ -2,6 +2,6 @@ pub mod generated; pub mod versioned; // Re-export latest. -pub use generated::v5::*; +pub use generated::v6::*; -pub const PROTOCOL_VERSION: u16 = 5; +pub const PROTOCOL_VERSION: u16 = 6; diff --git a/rivetkit-rust/packages/inspector-protocol/src/versioned.rs b/rivetkit-rust/packages/inspector-protocol/src/versioned.rs index e163500ec7..e0ba316035 100644 --- a/rivetkit-rust/packages/inspector-protocol/src/versioned.rs +++ b/rivetkit-rust/packages/inspector-protocol/src/versioned.rs @@ -2,12 +2,13 @@ use anyhow::{Result, bail}; use serde_bare::Uint; use vbare::OwnedVersionedData; -use crate::generated::{v1, v2, v3, v4, v5}; +use crate::generated::{v1, v2, v3, v4, v5, v6}; const WORKFLOW_HISTORY_DROPPED_ERROR: &str = "inspector.workflow_history_dropped"; const QUEUE_DROPPED_ERROR: &str = "inspector.queue_dropped"; const TRACE_DROPPED_ERROR: &str = "inspector.trace_dropped"; const DATABASE_DROPPED_ERROR: &str = "inspector.database_dropped"; +const SCHEDULES_DROPPED_ERROR: &str = "inspector.schedules_dropped"; pub enum ToServer { V1(v1::ToServer), @@ -15,18 +16,19 @@ pub enum ToServer { V3(v3::ToServer), V4(v4::ToServer), V5(v5::ToServer), + V6(v6::ToServer), } impl OwnedVersionedData for ToServer { - type Latest = v5::ToServer; + type Latest = v6::ToServer; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V5(latest) + Self::V6(latest) } fn unwrap_latest(self) -> Result { match self { - Self::V5(data) => Ok(data), + Self::V6(data) => Ok(data), _ => bail!("version not latest"), } } @@ -38,6 +40,7 @@ impl OwnedVersionedData for ToServer { 3 => Ok(Self::V3(serde_bare::from_slice(payload)?)), 4 => Ok(Self::V4(serde_bare::from_slice(payload)?)), 5 => Ok(Self::V5(serde_bare::from_slice(payload)?)), + 6 => Ok(Self::V6(serde_bare::from_slice(payload)?)), _ => bail!("invalid inspector protocol version for ToServer: {version}"), } } @@ -49,6 +52,7 @@ impl OwnedVersionedData for ToServer { (Self::V3(data), 3) => serde_bare::to_vec(&data).map_err(Into::into), (Self::V4(data), 4) => serde_bare::to_vec(&data).map_err(Into::into), (Self::V5(data), 5) => serde_bare::to_vec(&data).map_err(Into::into), + (Self::V6(data), 6) => serde_bare::to_vec(&data).map_err(Into::into), (_, version) => bail!("unexpected inspector protocol version for ToServer: {version}"), } } @@ -59,11 +63,13 @@ impl OwnedVersionedData for ToServer { Self::v2_to_v3, Self::v3_to_v4, Self::v4_to_v5, + Self::v5_to_v6, ] } fn serialize_converters() -> Vec Result> { vec![ + Self::v6_to_v5, Self::v5_to_v4, Self::v4_to_v3, Self::v3_to_v2, @@ -143,6 +149,56 @@ impl ToServer { Ok(Self::V5(data.into())) } + fn v5_to_v6(self) -> Result { + let Self::V5(data) = self else { + bail!("expected inspector protocol v5 ToServer") + }; + Ok(Self::V6(data.into())) + } + + fn v6_to_v5(self) -> Result { + let Self::V6(data) = self else { + bail!("expected inspector protocol v6 ToServer") + }; + + let body = match data.body { + v6::ToServerBody::PatchStateRequest(req) => { + v5::ToServerBody::PatchStateRequest(req.into()) + } + v6::ToServerBody::StateRequest(req) => v5::ToServerBody::StateRequest(req.into()), + v6::ToServerBody::ConnectionsRequest(req) => { + v5::ToServerBody::ConnectionsRequest(req.into()) + } + v6::ToServerBody::ActionRequest(req) => v5::ToServerBody::ActionRequest(req.into()), + v6::ToServerBody::RpcsListRequest(req) => { + v5::ToServerBody::RpcsListRequest(req.into()) + } + v6::ToServerBody::TraceQueryRequest(req) => { + v5::ToServerBody::TraceQueryRequest(req.into()) + } + v6::ToServerBody::QueueRequest(req) => v5::ToServerBody::QueueRequest(req.into()), + v6::ToServerBody::WorkflowHistoryRequest(req) => { + v5::ToServerBody::WorkflowHistoryRequest(req.into()) + } + v6::ToServerBody::WorkflowReplayRequest(req) => { + v5::ToServerBody::WorkflowReplayRequest(req.into()) + } + v6::ToServerBody::DatabaseSchemaRequest(req) => { + v5::ToServerBody::DatabaseSchemaRequest(req.into()) + } + v6::ToServerBody::DatabaseTableRowsRequest(req) => { + v5::ToServerBody::DatabaseTableRowsRequest(req.into()) + } + v6::ToServerBody::SchedulesRequest(_) + | v6::ToServerBody::ScheduleHistoryRequest(_) + | v6::ToServerBody::ScheduleDeleteRequest(_) => { + bail!("cannot convert inspector v6 schedule requests to v5") + } + }; + + Ok(Self::V5(v5::ToServer { body })) + } + fn v5_to_v4(self) -> Result { let Self::V5(data) = self else { bail!("expected inspector protocol v5 ToServer") @@ -249,18 +305,19 @@ pub enum ToClient { V3(v3::ToClient), V4(v4::ToClient), V5(v5::ToClient), + V6(v6::ToClient), } impl OwnedVersionedData for ToClient { - type Latest = v5::ToClient; + type Latest = v6::ToClient; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V5(latest) + Self::V6(latest) } fn unwrap_latest(self) -> Result { match self { - Self::V5(data) => Ok(data), + Self::V6(data) => Ok(data), _ => bail!("version not latest"), } } @@ -272,6 +329,7 @@ impl OwnedVersionedData for ToClient { 3 => Ok(Self::V3(serde_bare::from_slice(payload)?)), 4 => Ok(Self::V4(serde_bare::from_slice(payload)?)), 5 => Ok(Self::V5(serde_bare::from_slice(payload)?)), + 6 => Ok(Self::V6(serde_bare::from_slice(payload)?)), _ => bail!("invalid inspector protocol version for ToClient: {version}"), } } @@ -283,6 +341,7 @@ impl OwnedVersionedData for ToClient { (Self::V3(data), 3) => serde_bare::to_vec(&data).map_err(Into::into), (Self::V4(data), 4) => serde_bare::to_vec(&data).map_err(Into::into), (Self::V5(data), 5) => serde_bare::to_vec(&data).map_err(Into::into), + (Self::V6(data), 6) => serde_bare::to_vec(&data).map_err(Into::into), (_, version) => bail!("unexpected inspector protocol version for ToClient: {version}"), } } @@ -293,11 +352,13 @@ impl OwnedVersionedData for ToClient { Self::v2_to_v3, Self::v3_to_v4, Self::v4_to_v5, + Self::v5_to_v6, ] } fn serialize_converters() -> Vec Result> { vec![ + Self::v6_to_v5, Self::v5_to_v4, Self::v4_to_v3, Self::v3_to_v2, @@ -451,6 +512,129 @@ impl ToClient { Ok(Self::V5(v5::ToClient { body })) } + fn v5_to_v6(self) -> Result { + let Self::V5(data) = self else { + bail!("expected inspector protocol v5 ToClient") + }; + + let body = match data.body { + v5::ToClientBody::StateResponse(resp) => v6::ToClientBody::StateResponse(resp.into()), + v5::ToClientBody::ConnectionsResponse(resp) => { + v6::ToClientBody::ConnectionsResponse(resp.into()) + } + v5::ToClientBody::ActionResponse(resp) => { + v6::ToClientBody::ActionResponse(resp.into()) + } + v5::ToClientBody::ConnectionsUpdated(update) => { + v6::ToClientBody::ConnectionsUpdated(update.into()) + } + v5::ToClientBody::QueueUpdated(update) => v6::ToClientBody::QueueUpdated(update.into()), + v5::ToClientBody::StateUpdated(update) => v6::ToClientBody::StateUpdated(update.into()), + v5::ToClientBody::WorkflowHistoryUpdated(update) => { + v6::ToClientBody::WorkflowHistoryUpdated(update.into()) + } + v5::ToClientBody::RpcsListResponse(resp) => { + v6::ToClientBody::RpcsListResponse(resp.into()) + } + v5::ToClientBody::TraceQueryResponse(resp) => { + v6::ToClientBody::TraceQueryResponse(resp.into()) + } + v5::ToClientBody::QueueResponse(resp) => v6::ToClientBody::QueueResponse(resp.into()), + v5::ToClientBody::WorkflowHistoryResponse(resp) => { + v6::ToClientBody::WorkflowHistoryResponse(resp.into()) + } + v5::ToClientBody::WorkflowReplayResponse(resp) => { + v6::ToClientBody::WorkflowReplayResponse(resp.into()) + } + v5::ToClientBody::Error(error) => v6::ToClientBody::Error(error.into()), + v5::ToClientBody::Init(init) => v6::ToClientBody::Init(v6::Init { + connections: convert_vec(init.connections), + state: init.state, + is_state_enabled: init.is_state_enabled, + rpcs: init.rpcs, + is_database_enabled: init.is_database_enabled, + queue_size: init.queue_size, + workflow_history: init.workflow_history, + is_workflow_enabled: init.is_workflow_enabled, + tab_config: convert_vec(init.tab_config), + schedules: Vec::new(), + }), + v5::ToClientBody::DatabaseSchemaResponse(resp) => { + v6::ToClientBody::DatabaseSchemaResponse(resp.into()) + } + v5::ToClientBody::DatabaseTableRowsResponse(resp) => { + v6::ToClientBody::DatabaseTableRowsResponse(resp.into()) + } + }; + + Ok(Self::V6(v6::ToClient { body })) + } + + fn v6_to_v5(self) -> Result { + let Self::V6(data) = self else { + bail!("expected inspector protocol v6 ToClient") + }; + + let body = match data.body { + v6::ToClientBody::StateResponse(resp) => v5::ToClientBody::StateResponse(resp.into()), + v6::ToClientBody::ConnectionsResponse(resp) => { + v5::ToClientBody::ConnectionsResponse(resp.into()) + } + v6::ToClientBody::ActionResponse(resp) => { + v5::ToClientBody::ActionResponse(resp.into()) + } + v6::ToClientBody::ConnectionsUpdated(update) => { + v5::ToClientBody::ConnectionsUpdated(update.into()) + } + v6::ToClientBody::QueueUpdated(update) => v5::ToClientBody::QueueUpdated(update.into()), + v6::ToClientBody::StateUpdated(update) => v5::ToClientBody::StateUpdated(update.into()), + v6::ToClientBody::WorkflowHistoryUpdated(update) => { + v5::ToClientBody::WorkflowHistoryUpdated(update.into()) + } + v6::ToClientBody::RpcsListResponse(resp) => { + v5::ToClientBody::RpcsListResponse(resp.into()) + } + v6::ToClientBody::TraceQueryResponse(resp) => { + v5::ToClientBody::TraceQueryResponse(resp.into()) + } + v6::ToClientBody::QueueResponse(resp) => v5::ToClientBody::QueueResponse(resp.into()), + v6::ToClientBody::WorkflowHistoryResponse(resp) => { + v5::ToClientBody::WorkflowHistoryResponse(resp.into()) + } + v6::ToClientBody::WorkflowReplayResponse(resp) => { + v5::ToClientBody::WorkflowReplayResponse(resp.into()) + } + v6::ToClientBody::Error(error) => v5::ToClientBody::Error(error.into()), + v6::ToClientBody::Init(init) => v5::ToClientBody::Init(v5::Init { + connections: convert_vec(init.connections), + state: init.state, + is_state_enabled: init.is_state_enabled, + rpcs: init.rpcs, + is_database_enabled: init.is_database_enabled, + queue_size: init.queue_size, + workflow_history: init.workflow_history, + is_workflow_enabled: init.is_workflow_enabled, + tab_config: convert_vec(init.tab_config), + }), + v6::ToClientBody::DatabaseSchemaResponse(resp) => { + v5::ToClientBody::DatabaseSchemaResponse(resp.into()) + } + v6::ToClientBody::DatabaseTableRowsResponse(resp) => { + v5::ToClientBody::DatabaseTableRowsResponse(resp.into()) + } + v6::ToClientBody::SchedulesUpdated(_) + | v6::ToClientBody::SchedulesResponse(_) + | v6::ToClientBody::ScheduleHistoryResponse(_) + | v6::ToClientBody::ScheduleDeleteResponse(_) => { + v5::ToClientBody::Error(v5::Error { + message: SCHEDULES_DROPPED_ERROR.to_owned(), + }) + } + }; + + Ok(Self::V5(v5::ToClient { body })) + } + fn v5_to_v4(self) -> Result { let Self::V5(data) = self else { bail!("expected inspector protocol v5 ToClient") @@ -875,10 +1059,96 @@ impl_common_actor_pair!(v1, v2); impl_common_actor_pair!(v2, v3); impl_common_actor_pair!(v3, v4); impl_common_actor_pair!(v4, v5); +impl_common_actor_pair!(v5, v6); impl_queue_workflow_pair!(v2, v3); impl_queue_workflow_pair!(v3, v4); impl_database_pair!(v3, v4); impl_database_pair!(v4, v5); +impl_database_pair!(v5, v6); + +// v5 <-> v6 differ in `Init` and v6 adds schedule messages. Shared types are +// converted field-by-field; schedule-only messages are handled by the v6 +// converter above so older clients receive a structured dropped-feature error. +impl_same_fields_pair!(v5, v6, TabConfigEntry { id, label, icon, hidden }); +impl_same_fields_pair!( + v5, + v6, + TraceQueryRequest { + id, + start_ms, + end_ms, + limit, + } +); +impl_same_fields_pair!(v5, v6, TraceQueryResponse { rid, payload }); +impl_same_fields_pair!(v5, v6, QueueRequest { id, limit }); +impl_same_fields_pair!( + v5, + v6, + QueueMessageSummary { + id, + name, + created_at_ms, + } +); +impl_queue_status_pair!(v5, v6); +impl_queue_response_pair!(v5, v6); +impl_same_fields_pair!(v5, v6, QueueUpdated { queue_size }); +impl_same_fields_pair!(v5, v6, WorkflowHistoryRequest { id }); +impl_same_fields_pair!( + v5, + v6, + WorkflowHistoryResponse { + rid, + history, + is_workflow_enabled, + } +); +impl_same_fields_pair!(v5, v6, WorkflowHistoryUpdated { history }); +impl_same_fields_pair!(v5, v6, WorkflowReplayRequest { id, entry_id }); +impl_same_fields_pair!( + v5, + v6, + WorkflowReplayResponse { + rid, + history, + is_workflow_enabled, + } +); + +impl From for v6::ToServerBody { + fn from(value: v5::ToServerBody) -> Self { + match value { + v5::ToServerBody::PatchStateRequest(req) => Self::PatchStateRequest(req.into()), + v5::ToServerBody::StateRequest(req) => Self::StateRequest(req.into()), + v5::ToServerBody::ConnectionsRequest(req) => Self::ConnectionsRequest(req.into()), + v5::ToServerBody::ActionRequest(req) => Self::ActionRequest(req.into()), + v5::ToServerBody::RpcsListRequest(req) => Self::RpcsListRequest(req.into()), + v5::ToServerBody::TraceQueryRequest(req) => Self::TraceQueryRequest(req.into()), + v5::ToServerBody::QueueRequest(req) => Self::QueueRequest(req.into()), + v5::ToServerBody::WorkflowHistoryRequest(req) => { + Self::WorkflowHistoryRequest(req.into()) + } + v5::ToServerBody::WorkflowReplayRequest(req) => { + Self::WorkflowReplayRequest(req.into()) + } + v5::ToServerBody::DatabaseSchemaRequest(req) => { + Self::DatabaseSchemaRequest(req.into()) + } + v5::ToServerBody::DatabaseTableRowsRequest(req) => { + Self::DatabaseTableRowsRequest(req.into()) + } + } + } +} + +impl From for v6::ToServer { + fn from(value: v5::ToServer) -> Self { + Self { + body: value.body.into(), + } + } +} // v4 <-> v5 differ only in `Init` (v5 adds `tab_config`), which is converted // inline in the ToClient converters. Every other type is field-identical, so @@ -1143,20 +1413,58 @@ mod tests { #[test] fn v5_init_tab_config_round_trips_at_version_5() { let original = v5_init_with_tabs(); - let encoded = ToClient::V5(v5::ToClient { + let latest = ToClient::v5_to_v6(ToClient::V5(v5::ToClient { body: v5::ToClientBody::Init(original.clone()), - }) + })) + .unwrap(); + let encoded = latest .serialize_with_embedded_version(5) .unwrap(); // `deserialize_with_embedded_version` upgrades to and returns the latest - // wire type (v5::ToClient), not the versioned enum. + // wire type (v6::ToClient), not the versioned enum. let decoded = ::deserialize_with_embedded_version(&encoded).unwrap(); - let v5::ToClientBody::Init(init) = decoded.body else { + let v6::ToClientBody::Init(init) = decoded.body else { + panic!("expected Init body") + }; + assert_eq!(init.tab_config.len(), original.tab_config.len()); + assert_eq!(init.tab_config[0].id, original.tab_config[0].id); + assert!(init.schedules.is_empty()); + } + + #[test] + fn v6_schedule_messages_downgrade_to_structured_error() { + let response = ToClient::V6(v6::ToClient { + body: v6::ToClientBody::SchedulesUpdated(v6::SchedulesUpdated { + schedules: Vec::new(), + }), + }); + + let ToClient::V5(downgraded) = ToClient::v6_to_v5(response).unwrap() else { + panic!("expected v5 response") + }; + assert_eq!( + downgraded.body, + v5::ToClientBody::Error(v5::Error { + message: SCHEDULES_DROPPED_ERROR.to_owned(), + }) + ); + } + + #[test] + fn v5_init_upgrades_to_v6_with_empty_schedules() { + let response = ToClient::V5(v5::ToClient { + body: v5::ToClientBody::Init(v5_init_with_tabs()), + }); + let ToClient::V6(upgraded) = ToClient::v5_to_v6(response).unwrap() else { + panic!("expected v6 response") + }; + let v6::ToClientBody::Init(init) = upgraded.body else { panic!("expected Init body") }; - assert_eq!(init.tab_config, original.tab_config); + assert!(init.schedules.is_empty()); + assert_eq!(init.tab_config.len(), 1); } #[test] diff --git a/rivetkit-rust/packages/rivetkit-core/Cargo.toml b/rivetkit-rust/packages/rivetkit-core/Cargo.toml index 90f664a621..2f6ea41a8d 100644 --- a/rivetkit-rust/packages/rivetkit-core/Cargo.toml +++ b/rivetkit-rust/packages/rivetkit-core/Cargo.toml @@ -40,6 +40,9 @@ axum = { workspace = true, optional = true } base64.workspace = true bytes = { workspace = true, optional = true } ciborium.workspace = true +chrono.workspace = true +chrono-tz.workspace = true +croner.workspace = true futures.workspace = true http.workspace = true http-body-util = { workspace = true, optional = true } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs index 451aa072bc..4ad4b99434 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/config.rs @@ -18,6 +18,7 @@ const DEFAULT_SLEEP_GRACE_PERIOD: Duration = Duration::from_secs(15); const DEFAULT_CONNECTION_LIVENESS_TIMEOUT: Duration = Duration::from_millis(2500); const DEFAULT_CONNECTION_LIVENESS_INTERVAL: Duration = Duration::from_secs(5); const DEFAULT_MAX_QUEUE_SIZE: u32 = 1000; +pub const DEFAULT_MAX_SCHEDULES: u32 = 1_000; const DEFAULT_MAX_QUEUE_MESSAGE_SIZE: u32 = 65_536; const DEFAULT_MAX_INCOMING_MESSAGE_SIZE: u32 = 65_536; const DEFAULT_MAX_OUTGOING_MESSAGE_SIZE: u32 = 1_048_576; @@ -81,6 +82,7 @@ pub struct ActorConfig { pub connection_liveness_timeout: Duration, pub connection_liveness_interval: Duration, pub max_queue_size: u32, + pub max_schedules: u32, pub max_queue_message_size: u32, pub max_incoming_message_size: u32, pub max_outgoing_message_size: u32, @@ -114,6 +116,7 @@ pub struct ActorConfigInput { pub connection_liveness_timeout_ms: Option, pub connection_liveness_interval_ms: Option, pub max_queue_size: Option, + pub max_schedules: Option, pub max_queue_message_size: Option, pub max_incoming_message_size: Option, pub max_outgoing_message_size: Option, @@ -176,6 +179,9 @@ impl ActorConfig { if let Some(value) = config.max_queue_size { actor_config.max_queue_size = value; } + if let Some(value) = config.max_schedules { + actor_config.max_schedules = value; + } if let Some(value) = config.max_queue_message_size { actor_config.max_queue_message_size = value; } @@ -238,6 +244,7 @@ impl Default for ActorConfig { connection_liveness_timeout: DEFAULT_CONNECTION_LIVENESS_TIMEOUT, connection_liveness_interval: DEFAULT_CONNECTION_LIVENESS_INTERVAL, max_queue_size: DEFAULT_MAX_QUEUE_SIZE, + max_schedules: DEFAULT_MAX_SCHEDULES, max_queue_message_size: DEFAULT_MAX_QUEUE_MESSAGE_SIZE, max_incoming_message_size: DEFAULT_MAX_INCOMING_MESSAGE_SIZE, max_outgoing_message_size: DEFAULT_MAX_OUTGOING_MESSAGE_SIZE, diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs index ab02a4c284..60f9e11201 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs @@ -1,11 +1,13 @@ use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; use std::sync::Weak; +#[cfg(any(test, feature = "test-support"))] +use std::sync::atomic::AtomicI64; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; use std::time::Duration; -use crate::time::{Instant, SystemTime, UNIX_EPOCH}; +use crate::time::Instant; use anyhow::{Context as AnyhowContext, Result}; use futures::future::BoxFuture; @@ -14,6 +16,7 @@ use rivet_envoy_client::handle::EnvoyHandle; use rivet_envoy_client::tunnel::HibernatingWebSocketMetadata; use rivet_error::ActorSpecifier; use scc::HashMap as SccHashMap; +use scc::HashSet as SccHashSet; use tokio::runtime::Handle; use tokio::sync::{Mutex as AsyncMutex, Notify, OnceCell, broadcast, mpsc, oneshot}; use tokio::task::JoinHandle; @@ -111,8 +114,15 @@ pub(crate) struct ActorContextInner { pub(super) schedule_local_alarm_epoch: AtomicU64, pub(super) schedule_alarm_dispatch_enabled: AtomicBool, pub(super) schedule_dirty_since_push: AtomicBool, + pub(super) schedule_mutation_lock: AsyncMutex<()>, + pub(super) schedule_running: SccHashSet, + pub(super) max_schedules: u32, + #[cfg(any(test, feature = "test-support"))] + pub(super) schedule_now_override: AtomicI64, #[cfg(test)] pub(super) schedule_driver_alarm_cancel_count: AtomicUsize, + #[cfg(test)] + pub(super) schedule_sync_alarm_failures: AtomicUsize, // Forced-sync: queue config is read from sync public methods before blocking // on async queue work. pub(super) queue_config: Mutex, @@ -272,6 +282,7 @@ impl ActorContext { let actor_runtime_socket = ActorRuntimeSocketEndpoint::new(config.enable_actor_runtime_socket, sql.clone()); let state_save_interval = config.state_save_interval; + let max_schedules = config.max_schedules; let abort_signal = CancellationToken::new(); let shutdown_deadline = CancellationToken::new(); let sleep = SleepState::new(config.clone()); @@ -318,8 +329,15 @@ impl ActorContext { // A fresh actor context has no in-process record of a successful // envoy alarm push yet, so the first sync must always push. schedule_dirty_since_push: AtomicBool::new(true), + schedule_mutation_lock: AsyncMutex::new(()), + schedule_running: SccHashSet::new(), + max_schedules, + #[cfg(any(test, feature = "test-support"))] + schedule_now_override: AtomicI64::new(i64::MIN), #[cfg(test)] schedule_driver_alarm_cancel_count: AtomicUsize::new(0), + #[cfg(test)] + schedule_sync_alarm_failures: AtomicUsize::new(0), queue_config: Mutex::new(config.clone()), queue_abort_signal: Mutex::new(abort_signal.clone()), queue_initialize: OnceCell::new(), @@ -464,8 +482,11 @@ impl ActorContext { /// Foreign-runtime adapters should call this during startup after loading /// any persisted schedule state and before accepting user callbacks that rely /// on future alarms being armed. - pub fn init_alarms(&self) { - self.sync_future_alarm_logged(); + pub async fn init_alarms(&self) { + if let Err(error) = self.recover_interrupted_schedule_history().await { + tracing::error!(?error, "failed to recover interrupted schedule history"); + } + self.sync_future_alarm_logged().await; } pub fn queue(&self) -> &Self { @@ -823,16 +844,9 @@ impl ActorContext { /// Foreign-runtime adapters should call this after startup callbacks complete /// so overdue scheduled work enters the normal actor event loop. pub async fn drain_overdue_scheduled_events(&self) -> Result<()> { - for event in self.due_scheduled_events(now_timestamp_ms()) { - self.dispatch_scheduled_action( - &event.event_id, - event.action, - event.args.unwrap_or_default(), - ) - .await; + for dispatch in self.take_due_schedule_dispatches().await? { + self.dispatch_scheduled_action(dispatch).await; } - - self.sync_alarm_logged(); Ok(()) } @@ -1532,6 +1546,12 @@ impl ActorContext { } } + pub(crate) fn record_schedules_updated(&self) { + if let Some(inspector) = self.inspector() { + inspector.record_schedules_updated(); + } + } + pub(crate) async fn save_state_with_revision( &self, deltas: Vec, @@ -1570,10 +1590,17 @@ impl ActorContext { Ok(()) } - async fn dispatch_scheduled_action(&self, event_id: &str, action: String, args: Vec) { - self.cancel_scheduled_event(event_id); + async fn dispatch_scheduled_action( + &self, + dispatch: crate::actor::schedule::DueScheduleDispatch, + ) { let ctx = self.clone(); - let event_id = event_id.to_owned(); + let event_id = dispatch.event_id; + let action = dispatch.action; + let args = dispatch.args; + let scheduled_fire = dispatch.fire; + let recurring_name = scheduled_fire.name.clone(); + let history_id = dispatch.history_id; let internal_keep_awake_region = self.begin_work_region(ActorWorkKind::InternalKeepAwake); self.track_shutdown_task(async move { @@ -1583,11 +1610,13 @@ impl ActorContext { let action_name = action.clone(); let (reply_tx, reply_rx) = oneshot::channel(); + let mut dispatch_error = None; match ctx.try_send_actor_event( ActorEvent::Action { name: action.clone(), args, conn: None, + scheduled_fire: Some(scheduled_fire), reply: Reply::from(reply_tx), }, "scheduled_action", @@ -1595,16 +1624,18 @@ impl ActorContext { Ok(()) => match reply_rx.await { Ok(Ok(_)) => {} Ok(Err(error)) => { + dispatch_error = Some(error); tracing::error!( - ?error, + error = ?dispatch_error.as_ref().expect("just assigned"), event_id, action_name, "scheduled event execution failed" ); } Err(error) => { + dispatch_error = Some(error.into()); tracing::error!( - ?error, + error = ?dispatch_error.as_ref().expect("just assigned"), event_id, action_name, "scheduled event reply dropped" @@ -1612,8 +1643,9 @@ impl ActorContext { } }, Err(error) => { + dispatch_error = Some(error); tracing::error!( - ?error, + error = ?dispatch_error.as_ref().expect("just assigned"), event_id, action_name, "failed to enqueue scheduled event" @@ -1621,6 +1653,21 @@ impl ActorContext { } } + ctx.finish_schedule_dispatch(&event_id, history_id, dispatch_error.as_ref()) + .await; + if let (Some(name), Some(error)) = (recurring_name, dispatch_error.as_ref()) { + let structured = rivet_error::RivetError::extract(error); + if structured.group() == "actor" && structured.code() == "action_not_found" { + if let Err(delete_error) = ctx.cron_delete_if_action(&name, &action_name).await { + tracing::error!( + ?delete_error, + %name, + "failed to delete recurring schedule for missing action" + ); + } + } + } + ctx.record_user_task_finished(UserTaskKind::ScheduledAction, started_at.elapsed()); }); } @@ -1658,13 +1705,6 @@ fn truncate_stop_error_message(mut message: String) -> String { message } -fn now_timestamp_ms() -> i64 { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) -} - struct ActorWorkGuard { ctx: ActorContext, kind: ActorWorkKind, diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs index 2ae4dd0ef1..53ce54ccb3 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result, bail}; use crate::sqlite::{BindParam, ColumnValue, SqliteBatchStatement, SqliteDb}; -pub(crate) const INTERNAL_SCHEMA_VERSION: i64 = 7; +pub(crate) const INTERNAL_SCHEMA_VERSION: i64 = 6; const SCHEMA_VERSION_KEY: &str = "schema_version"; @@ -19,6 +19,12 @@ CREATE TABLE IF NOT EXISTS _rivet_meta ( ) STRICT, WITHOUT ROWID "#; +// The position and count of entries in this array are the persisted internal +// schema-version history. Once a version ships, changing or reordering an +// existing entry would make the same stored version describe different schemas +// across runtime releases. Rewriting these entries in place is safe only while +// no internal schema version has shipped; after release, all changes must be +// appended as new migrations and INTERNAL_SCHEMA_VERSION must advance. const MIGRATIONS: &[&[&str]] = &[ &[ // W[queue_next_id per enqueue; alarm per head-change; token once | point UPDATE of one column | <100 B | single-row: all runtime singletons on one leaf] @@ -27,7 +33,7 @@ CREATE TABLE _rivet_runtime ( id INTEGER PRIMARY KEY CHECK (id = 1), last_pushed_alarm INTEGER, inspector_token TEXT, - queue_next_id INTEGER NOT NULL DEFAULT 1 + queue_next_id INTEGER NOT NULL ) STRICT "#, // W[once at init | single INSERT | input <=256 KiB | COLD: never rewritten; overflow chain isolated from hot state] @@ -50,15 +56,43 @@ CREATE TABLE _rivet_actor_state ( // W[per schedule/cancel/fire, immediate | point insert/delete | <200 B | replaces full actor blob rewrite with one row] r#" CREATE TABLE _rivet_schedule_events ( - event_id TEXT PRIMARY KEY, - trigger_at INTEGER NOT NULL, - action TEXT NOT NULL, - args BLOB + event_id TEXT PRIMARY KEY, + trigger_at INTEGER NOT NULL, + action TEXT NOT NULL, + args BLOB, + kind INTEGER NOT NULL, + cron_expression TEXT, + timezone TEXT, + interval_ms INTEGER, + last_started_at INTEGER, + max_history INTEGER NOT NULL ) STRICT, WITHOUT ROWID "#, r#" CREATE INDEX _rivet_schedule_events_trigger_at ON _rivet_schedule_events (trigger_at) +"#, + // W[per recurring fire | point insert/update/prune | bounded rows] + r#" +CREATE TABLE _rivet_schedule_history ( + id INTEGER PRIMARY KEY, + schedule_id TEXT NOT NULL, + action TEXT NOT NULL, + scheduled_at INTEGER NOT NULL, + fired_at INTEGER NOT NULL, + finished_at INTEGER, + result INTEGER NOT NULL, + -- Mirrors the schedule-history subset of the canonical client-visible + -- RivetError payload. Keep these columns synchronized with ScheduleErrorInfo. + error_group TEXT, + error_code TEXT, + error_message TEXT, + error_metadata BLOB +) STRICT +"#, + r#" +CREATE INDEX _rivet_schedule_history_schedule + ON _rivet_schedule_history (schedule_id, fired_at DESC) "#, ], &[ @@ -79,6 +113,7 @@ CREATE TABLE _rivet_conn_state ( conn_id TEXT PRIMARY KEY, state BLOB NOT NULL, server_message_index INTEGER NOT NULL, + client_message_index INTEGER NOT NULL, subscriptions BLOB NOT NULL ) STRICT, WITHOUT ROWID "#, @@ -110,13 +145,6 @@ CREATE TABLE _rivet_user_kv ( key BLOB PRIMARY KEY, value BLOB NOT NULL ) STRICT, WITHOUT ROWID -"#, - ], - &[ - // W[dirty per client WS message, written with other conn state | point UPDATE | 2 B logical value | preserves the client ack cursor across hibernation] - r#" -ALTER TABLE _rivet_conn_state - ADD COLUMN client_message_index INTEGER NOT NULL DEFAULT 0 "#, ], ]; diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs index 25eb924022..51cf7c3fb1 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs @@ -9,7 +9,7 @@ use crate::actor::connection::{ use crate::actor::keys::make_workflow_key; use crate::actor::messages::WorkflowKvWrite; use crate::actor::queue::{PersistedQueueMessage, QueueMetadata}; -use crate::actor::state::{PersistedActor, PersistedScheduleEvent}; +use crate::actor::state::PersistedActor; use crate::error::KvRuntimeError; use crate::sqlite::{BindParam, ColumnValue, SqliteBatchStatement, SqliteDb}; use crate::types::ListOpts; @@ -82,7 +82,6 @@ pub(crate) async fn load_actor_snapshot(db: &SqliteDb) -> Result Result Result<()> { + let statements = vec![ + SqliteBatchStatement { + sql: "INSERT INTO _rivet_actor (id, has_initialized, input) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET has_initialized = excluded.has_initialized, input = excluded.input".to_owned(), + params: Some(vec![ + BindParam::Integer(if actor.has_initialized { 1 } else { 0 }), + optional_blob_param(actor.input.clone()), + ]), + }, + SqliteBatchStatement { + sql: "INSERT INTO _rivet_actor_state (id, state) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state".to_owned(), + params: Some(vec![BindParam::Blob(actor.state.clone())]), + }, + ]; + + db.execute_batch(statements) + .await + .context("persist internal actor snapshot")?; + Ok(()) +} + +/// Imports the schedule vector from the legacy versioned actor snapshot exactly +/// once. Runtime state flushes must use [`persist_actor_snapshot`] so SQLite +/// schedule rows remain authoritative and are never rewritten from the legacy +/// field. +pub(crate) async fn import_legacy_actor_snapshot( + db: &SqliteDb, + actor: &PersistedActor, +) -> Result<()> { let mut statements = vec![ SqliteBatchStatement { sql: "INSERT INTO _rivet_actor (id, has_initialized, input) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET has_initialized = excluded.has_initialized, input = excluded.input".to_owned(), @@ -114,22 +141,26 @@ pub(crate) async fn persist_actor_snapshot(db: &SqliteDb, actor: &PersistedActor params: None, }, ]; - for event in &actor.scheduled_events { statements.push(SqliteBatchStatement { - sql: "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args) VALUES (?, ?, ?, ?)".to_owned(), + sql: "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".to_owned(), params: Some(vec![ BindParam::Text(event.event_id.clone()), BindParam::Integer(event.timestamp), BindParam::Text(event.action.clone()), optional_blob_param(event.args.clone()), + BindParam::Integer(0), + BindParam::Null, + BindParam::Null, + BindParam::Null, + BindParam::Null, + BindParam::Integer(0), ]), }); } - db.execute_batch(statements) .await - .context("persist internal actor snapshot")?; + .context("import legacy actor snapshot")?; Ok(()) } @@ -193,25 +224,6 @@ fn build_actor_core_and_connection_statements( sql: "INSERT INTO _rivet_actor_state (id, state) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state".to_owned(), params: Some(vec![BindParam::Blob(actor.state.clone())]), }); - // Rewrite schedule events atomically with the state flush. A schedule - // mutation whose own tracked persist has not run yet must not be lost - // when a crash lands after this flush; the engine alarm may already be - // armed for it. - statements.push(SqliteBatchStatement { - sql: "DELETE FROM _rivet_schedule_events".to_owned(), - params: None, - }); - for event in &actor.scheduled_events { - statements.push(SqliteBatchStatement { - sql: "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args) VALUES (?, ?, ?, ?)".to_owned(), - params: Some(vec![ - BindParam::Text(event.event_id.clone()), - BindParam::Integer(event.timestamp), - BindParam::Text(event.action.clone()), - optional_blob_param(event.args.clone()), - ]), - }); - } } for connection in connections { @@ -378,8 +390,12 @@ pub(crate) async fn persist_queue_message( ]), }, SqliteBatchStatement { - sql: "INSERT INTO _rivet_runtime (id, queue_next_id) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET queue_next_id = excluded.queue_next_id".to_owned(), - params: Some(vec![BindParam::Integer(next_id)]), + sql: "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET queue_next_id = excluded.queue_next_id".to_owned(), + params: Some(vec![ + BindParam::Null, + BindParam::Null, + BindParam::Integer(next_id), + ]), }, ]) .await @@ -445,8 +461,12 @@ fn split_queue_tx_chunks( pub(crate) async fn persist_queue_next_id(db: &SqliteDb, next_id: u64) -> Result<()> { let next_id = i64::try_from(next_id).context("queue next id exceeds sqlite integer range")?; db.execute( - "INSERT INTO _rivet_runtime (id, queue_next_id) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET queue_next_id = excluded.queue_next_id", - Some(vec![BindParam::Integer(next_id)]), + "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET queue_next_id = excluded.queue_next_id", + Some(vec![ + BindParam::Null, + BindParam::Null, + BindParam::Integer(next_id), + ]), ) .await .context("persist internal queue next id")?; @@ -852,8 +872,12 @@ pub(crate) async fn load_last_pushed_alarm(db: &SqliteDb) -> Result> pub(crate) async fn persist_last_pushed_alarm(db: &SqliteDb, alarm_ts: Option) -> Result<()> { db.execute( - "INSERT INTO _rivet_runtime (id, last_pushed_alarm) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET last_pushed_alarm = excluded.last_pushed_alarm", - Some(vec![optional_i64_param(alarm_ts)]), + "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET last_pushed_alarm = excluded.last_pushed_alarm", + Some(vec![ + optional_i64_param(alarm_ts), + BindParam::Null, + BindParam::Integer(1), + ]), ) .await .context("persist internal last pushed alarm")?; @@ -876,8 +900,12 @@ pub(crate) async fn load_inspector_token(db: &SqliteDb) -> Result pub(crate) async fn persist_inspector_token(db: &SqliteDb, token: &str) -> Result<()> { db.execute( - "INSERT INTO _rivet_runtime (id, inspector_token) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET inspector_token = excluded.inspector_token", - Some(vec![BindParam::Text(token.to_owned())]), + "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET inspector_token = excluded.inspector_token", + Some(vec![ + BindParam::Null, + BindParam::Text(token.to_owned()), + BindParam::Integer(1), + ]), ) .await .context("persist internal inspector token")?; @@ -1034,29 +1062,6 @@ async fn clear_table_bounded( } } -async fn load_schedule_events(db: &SqliteDb) -> Result> { - let result = db - .query( - "SELECT event_id, trigger_at, action, args FROM _rivet_schedule_events ORDER BY trigger_at, event_id", - None, - ) - .await - .context("load internal schedule events")?; - - result - .rows - .iter() - .map(|row| { - Ok(PersistedScheduleEvent { - event_id: read_text(row, 0, "event_id")?, - timestamp: read_i64(row, 1, "trigger_at")?, - action: read_text(row, 2, "action")?, - args: read_optional_blob(row, 3, "args")?, - }) - }) - .collect() -} - fn optional_blob_param(value: Option>) -> BindParam { value.map(BindParam::Blob).unwrap_or(BindParam::Null) } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/messages.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/messages.rs index 0fbcaeff66..644f3df3cc 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/messages.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/messages.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::actor::connection::ConnHandle; use crate::actor::lifecycle_hooks::Reply; +use crate::actor::schedule::ScheduledFireInfo; use crate::actor::task_types::ShutdownKind; use crate::error::ProtocolError; use crate::types::ConnId; @@ -277,6 +278,7 @@ pub enum ActorEvent { name: String, args: Vec, conn: Option, + scheduled_fire: Option, reply: Reply>, }, HttpRequest { diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/migrate_kv_to_sqlite/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/migrate_kv_to_sqlite/mod.rs index 9f5f0e83d4..007cc202c9 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/migrate_kv_to_sqlite/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/migrate_kv_to_sqlite/mod.rs @@ -141,7 +141,7 @@ async fn import_core_state_if_needed_inner(ctx: &ActorContext) -> Result<()> { + usize::from(queue_metadata.is_some()); if let Some(actor) = actor { - internal_storage::persist_actor_snapshot(ctx.sql(), &actor) + internal_storage::import_legacy_actor_snapshot(ctx.sql(), &actor) .await .context("import legacy actor snapshot into sqlite")?; } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs index b5640b67b6..92852b6be0 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs @@ -2,290 +2,906 @@ use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; -use crate::time::{SystemTime, UNIX_EPOCH, sleep}; - -use anyhow::Result; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use chrono_tz::Tz; +use croner::Cron; use futures::future::BoxFuture; use rivet_envoy_client::handle::EnvoyHandle; +use rivet_error::RivetError; +use serde::{Deserialize, Serialize}; use tokio::runtime::Handle; use tokio::sync::oneshot; use tracing::Instrument; use uuid::Uuid; use crate::actor::context::ActorContext; -use crate::actor::state::PersistedScheduleEvent; -use crate::error::ActorRuntime; +use crate::error::{ScheduleRuntimeError, client_error_message, client_error_metadata}; #[cfg(feature = "wasm-runtime")] use crate::runtime::RuntimeSpawner; +use crate::sqlite::{BindParam, ColumnValue, SqliteBatchStatement}; +use crate::time::{SystemTime, UNIX_EPOCH, sleep}; + +const CRON_ID_PREFIX: &str = "cron:"; +const HISTORY_RUNNING: i64 = 0; +const HISTORY_OK: i64 = 1; +const HISTORY_ERROR: i64 = 2; +const HISTORY_SKIPPED: i64 = 3; +pub const DEFAULT_MAX_HISTORY: i64 = 100; +pub const MAX_HISTORY: i64 = 1_000; +pub const MAX_ACTOR_HISTORY: i64 = 10_000; +pub const MIN_INTERVAL_MS: i64 = 5_000; +const DEFAULT_HISTORY_LIMIT: i64 = 20; pub(super) type InternalKeepAwakeCallback = Arc>) -> BoxFuture<'static, Result<()>> + Send + Sync>; pub(super) type LocalAlarmCallback = Arc BoxFuture<'static, ()> + Send + Sync>; -impl ActorContext { - #[cfg(test)] - pub(crate) fn new_for_schedule_tests(actor_id: impl Into) -> Self { - Self::new(actor_id, "schedule-test", Vec::new(), "local") +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ScheduleKind { + At, + Cron, + Every, +} + +impl ScheduleKind { + pub fn as_str(self) -> &'static str { + match self { + Self::At => "at", + Self::Cron => "cron", + Self::Every => "every", + } } - pub fn after(&self, duration: Duration, action_name: &str, args: &[u8]) { - let duration_ms = i64::try_from(duration.as_millis()).unwrap_or(i64::MAX); - let timestamp_ms = now_timestamp_ms().saturating_add(duration_ms); - self.at(timestamp_ms, action_name, args); - } - - pub fn at(&self, timestamp_ms: i64, action_name: &str, args: &[u8]) { - if let Err(error) = self.schedule_event(timestamp_ms, action_name, args) { - tracing::error!( - actor_id = %self.actor_id(), - ?error, - action_name, - timestamp_ms, - "failed to schedule actor event" - ); + fn as_i64(self) -> i64 { + match self { + Self::At => 0, + Self::Cron => 1, + Self::Every => 2, } } - pub(crate) fn set_schedule_alarm(&self, timestamp_ms: Option) -> Result<()> { - let envoy_handle = self.0.schedule_envoy_handle.lock().clone().ok_or_else(|| { - ActorRuntime::NotConfigured { - component: "schedule alarm handle".to_owned(), + fn parse(value: i64, schedule_id: &str) -> Result { + match value { + 0 => Ok(Self::At), + 1 => Ok(Self::Cron), + 2 => Ok(Self::Every), + other => Err(ScheduleRuntimeError::InvalidScheduleRow { + schedule_id: schedule_id.to_owned(), + reason: format!("unknown kind {other}"), } - .build() - })?; - let generation = *self.0.schedule_generation.lock(); - self.set_alarm_tracked(envoy_handle, timestamp_ms, generation); - Ok(()) + .build()), + } } +} - pub(crate) fn configure_schedule_envoy( - &self, - envoy_handle: EnvoyHandle, - generation: Option, - ) { - *self.0.schedule_envoy_handle.lock() = Some(envoy_handle); - *self.0.schedule_generation.lock() = generation; +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledEventInfo { + pub id: String, + pub action: String, + pub args: Vec, + pub run_at: i64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CronJobInfo { + pub name: String, + pub kind: ScheduleKind, + pub action: String, + pub args: Vec, + pub next_run_at: i64, + pub last_run_at: Option, + pub expression: Option, + pub timezone: Option, + pub interval_ms: Option, + pub max_history: i64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledFireInfo { + pub kind: ScheduleKind, + pub id: String, + pub name: Option, + pub scheduled_at: i64, + pub fired_at: i64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ScheduleErrorInfo { + pub group: String, + pub code: String, + pub message: String, + pub metadata: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CronFire { + pub action: String, + pub scheduled_at: i64, + pub fired_at: i64, + pub finished_at: Option, + pub result: String, + pub error: Option, +} + +pub(crate) struct DueScheduleDispatch { + pub event_id: String, + pub action: String, + pub args: Vec, + pub fire: ScheduledFireInfo, + pub history_id: Option, +} + +#[derive(Clone, Debug)] +struct StoredSchedule { + event_id: String, + trigger_at: i64, + action: String, + args: Vec, + kind: ScheduleKind, + cron_expression: Option, + timezone: Option, + interval_ms: Option, + last_started_at: Option, + max_history: i64, +} + +impl ActorContext { + #[cfg(any(test, feature = "test-support"))] + pub fn set_schedule_time_for_tests(&self, timestamp_ms: i64) { + self.0 + .schedule_now_override + .store(timestamp_ms, Ordering::SeqCst); } - pub(crate) fn set_internal_keep_awake(&self, callback: Option) { - *self.0.schedule_internal_keep_awake.lock() = callback; + pub(crate) fn schedule_now_timestamp_ms(&self) -> i64 { + #[cfg(any(test, feature = "test-support"))] + { + let timestamp_ms = self.0.schedule_now_override.load(Ordering::SeqCst); + if timestamp_ms != i64::MIN { + return timestamp_ms; + } + } + system_now_timestamp_ms() } - pub(crate) fn set_local_alarm_callback(&self, callback: Option) { - *self.0.schedule_local_alarm_callback.lock() = callback; + pub async fn after( + &self, + duration: Duration, + action_name: &str, + args: &[u8], + ) -> Result { + let duration_ms = i64::try_from(duration.as_millis()).unwrap_or(i64::MAX); + let timestamp_ms = self.schedule_now_timestamp_ms().saturating_add(duration_ms); + self.at(timestamp_ms, action_name, args).await } - pub(crate) fn cancel_scheduled_event(&self, event_id: &str) -> bool { - let removed = self.update_scheduled_events(|events| { - let before = events.len(); - events.retain(|event| event.event_id != event_id); - before != events.len() - }); + pub async fn at(&self, timestamp_ms: i64, action_name: &str, args: &[u8]) -> Result { + let _mutation = self.0.schedule_mutation_lock.lock().await; + self.ensure_schedule_capacity(false).await?; + let event_id = Uuid::new_v4().to_string(); + self.sql() + .execute( + "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + Some(vec![ + BindParam::Text(event_id.clone()), + BindParam::Integer(timestamp_ms), + BindParam::Text(action_name.to_owned()), + args_param(args), + BindParam::Integer(ScheduleKind::At.as_i64()), + BindParam::Null, + BindParam::Null, + BindParam::Null, + BindParam::Null, + BindParam::Integer(0), + ]), + ) + .await + .context("insert one-shot schedule")?; + self.mark_schedule_dirty(); + self.record_schedules_updated(); + self.sync_alarm().await?; + Ok(event_id) + } + pub async fn cancel_schedule(&self, event_id: &str) -> Result { + let _mutation = self.0.schedule_mutation_lock.lock().await; + let result = self + .sql() + .execute( + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?", + Some(vec![ + BindParam::Text(event_id.to_owned()), + BindParam::Integer(ScheduleKind::At.as_i64()), + ]), + ) + .await + .context("cancel one-shot schedule")?; + let removed = result.changes > 0; if removed { - tracing::debug!( - actor_id = %self.actor_id(), - event_id, - "scheduled actor event cancelled" - ); - self.mark_dirty_since_push(); - self.persist_scheduled_events("schedule_cancel"); - self.sync_alarm_logged(); + self.mark_schedule_dirty(); + self.record_schedules_updated(); + self.sync_alarm().await?; } + Ok(removed) + } - removed + pub async fn get_scheduled_event(&self, event_id: &str) -> Result> { + let result = self + .sql() + .query( + "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?", + Some(vec![ + BindParam::Text(event_id.to_owned()), + BindParam::Integer(ScheduleKind::At.as_i64()), + ]), + ) + .await + .context("get one-shot schedule")?; + result + .rows + .first() + .map(|row| read_stored_schedule(row)) + .transpose() + .map(|row| { + row.map(|event| ScheduledEventInfo { + id: event.event_id, + action: event.action, + args: event.args, + run_at: event.trigger_at, + }) + }) } - pub(crate) fn next_event(&self) -> Option { - self.scheduled_events().into_iter().next() + pub async fn list_scheduled_events(&self) -> Result> { + let result = self + .sql() + .query( + "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind = ? ORDER BY trigger_at, event_id", + Some(vec![BindParam::Integer(ScheduleKind::At.as_i64())]), + ) + .await + .context("list one-shot schedules")?; + result + .rows + .iter() + .map(|row| read_stored_schedule(row)) + .map(|event| { + event.map(|event| ScheduledEventInfo { + id: event.event_id, + action: event.action, + args: event.args, + run_at: event.trigger_at, + }) + }) + .collect() } - pub(crate) fn all_events(&self) -> Vec { - self.scheduled_events() + pub async fn cron_set( + &self, + name: &str, + expression: &str, + timezone: Option<&str>, + action_name: &str, + args: &[u8], + max_history: Option, + ) -> Result<()> { + validate_name(name)?; + let timezone = timezone.unwrap_or("UTC"); + let timezone_parsed = parse_timezone(timezone)?; + let cron = parse_cron(expression)?; + let max_history = validate_max_history(max_history)?; + let now_ms = self.schedule_now_timestamp_ms(); + let event_id = cron_event_id(name); + let _mutation = self.0.schedule_mutation_lock.lock().await; + let existing = self.load_schedule(&event_id).await?; + self.ensure_schedule_capacity(existing.is_some()).await?; + let cadence_unchanged = existing.as_ref().is_some_and(|existing| { + existing.kind == ScheduleKind::Cron + && existing.cron_expression.as_deref() == Some(expression) + && existing.timezone.as_deref() == Some(timezone) + }); + let trigger_at = if cadence_unchanged { + existing.as_ref().expect("checked above").trigger_at + } else { + next_cron_timestamp_ms(&cron, timezone_parsed, now_ms)? + }; + self.upsert_recurring( + &event_id, + trigger_at, + action_name, + args, + ScheduleKind::Cron, + Some(expression), + Some(timezone), + None, + max_history, + ) + .await?; + self.prune_schedule_history(&event_id, max_history).await?; + self.mark_schedule_dirty(); + self.record_schedules_updated(); + self.sync_alarm().await } - pub(crate) fn cancel_local_alarm_timeouts(&self) { - self.0 - .schedule_local_alarm_epoch - .fetch_add(1, Ordering::SeqCst); - if let Some(handle) = self.0.schedule_local_alarm_task.lock().take() { - handle.abort(); + pub async fn cron_every( + &self, + name: &str, + interval_ms: i64, + action_name: &str, + args: &[u8], + max_history: Option, + ) -> Result<()> { + validate_name(name)?; + if interval_ms < MIN_INTERVAL_MS { + return Err(ScheduleRuntimeError::InvalidInterval { + interval_ms, + minimum_ms: MIN_INTERVAL_MS, + } + .build()); } + let max_history = validate_max_history(max_history)?; + let event_id = cron_event_id(name); + let now_ms = self.schedule_now_timestamp_ms(); + let _mutation = self.0.schedule_mutation_lock.lock().await; + let existing = self.load_schedule(&event_id).await?; + self.ensure_schedule_capacity(existing.is_some()).await?; + let cadence_unchanged = existing.as_ref().is_some_and(|existing| { + existing.kind == ScheduleKind::Every && existing.interval_ms == Some(interval_ms) + }); + let trigger_at = if cadence_unchanged { + existing.as_ref().expect("checked above").trigger_at + } else { + now_ms.saturating_add(interval_ms) + }; + self.upsert_recurring( + &event_id, + trigger_at, + action_name, + args, + ScheduleKind::Every, + None, + None, + Some(interval_ms), + max_history, + ) + .await?; + self.prune_schedule_history(&event_id, max_history).await?; + self.mark_schedule_dirty(); + self.record_schedules_updated(); + self.sync_alarm().await } - pub(crate) fn cancel_driver_alarm_logged(&self) { - self.cancel_local_alarm_timeouts(); - #[cfg(test)] - self.0 - .schedule_driver_alarm_cancel_count - .fetch_add(1, Ordering::SeqCst); + #[allow(clippy::too_many_arguments)] + async fn upsert_recurring( + &self, + event_id: &str, + trigger_at: i64, + action_name: &str, + args: &[u8], + kind: ScheduleKind, + cron_expression: Option<&str>, + timezone: Option<&str>, + interval_ms: Option, + max_history: i64, + ) -> Result<()> { + self.sql() + .execute( + "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_id) DO UPDATE SET trigger_at = excluded.trigger_at, action = excluded.action, args = excluded.args, kind = excluded.kind, cron_expression = excluded.cron_expression, timezone = excluded.timezone, interval_ms = excluded.interval_ms, max_history = excluded.max_history", + Some(vec![ + BindParam::Text(event_id.to_owned()), + BindParam::Integer(trigger_at), + BindParam::Text(action_name.to_owned()), + args_param(args), + BindParam::Integer(kind.as_i64()), + optional_text_param(cron_expression), + optional_text_param(timezone), + optional_i64_param(interval_ms), + BindParam::Null, + BindParam::Integer(max_history), + ]), + ) + .await + .context("upsert recurring schedule")?; + Ok(()) + } - let envoy_handle = self.0.schedule_envoy_handle.lock().clone(); - let Some(envoy_handle) = envoy_handle else { - return; - }; + pub async fn cron_delete(&self, name: &str) -> Result { + validate_name(name)?; + let _mutation = self.0.schedule_mutation_lock.lock().await; + let result = self + .sql() + .execute( + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?", + Some(vec![ + BindParam::Text(cron_event_id(name)), + BindParam::Integer(ScheduleKind::At.as_i64()), + ]), + ) + .await + .context("delete recurring schedule")?; + let removed = result.changes > 0; + if removed { + self.mark_schedule_dirty(); + self.record_schedules_updated(); + self.sync_alarm().await?; + } + Ok(removed) + } - let generation = *self.0.schedule_generation.lock(); - self.set_alarm_tracked(envoy_handle, None, generation); + pub(crate) async fn cron_delete_if_action(&self, name: &str, action: &str) -> Result { + validate_name(name)?; + let _mutation = self.0.schedule_mutation_lock.lock().await; + let result = self + .sql() + .execute( + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ? AND action = ?", + Some(vec![ + BindParam::Text(cron_event_id(name)), + BindParam::Integer(ScheduleKind::At.as_i64()), + BindParam::Text(action.to_owned()), + ]), + ) + .await + .context("delete recurring schedule with matching action")?; + let removed = result.changes > 0; + if removed { + self.mark_schedule_dirty(); + self.record_schedules_updated(); + self.sync_alarm().await?; + } + Ok(removed) } - #[cfg(test)] - pub(crate) fn test_driver_alarm_cancel_count(&self) -> usize { - self.0 - .schedule_driver_alarm_cancel_count - .load(Ordering::SeqCst) + pub async fn cron_get(&self, name: &str) -> Result> { + validate_name(name)?; + self.load_schedule(&cron_event_id(name)) + .await? + .map(stored_to_cron_info) + .transpose() } - pub(crate) async fn wait_for_pending_alarm_writes(&self) { - let pending = { - let mut guard = self.0.schedule_pending_alarm_writes.lock(); - std::mem::take(&mut *guard) - }; - tracing::debug!( - actor_id = %self.actor_id(), - pending_alarm_writes = pending.len(), - "waiting for pending actor alarm writes" - ); + pub async fn cron_list(&self) -> Result> { + let result = self + .sql() + .query( + "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind != ? ORDER BY trigger_at, event_id", + Some(vec![BindParam::Integer(ScheduleKind::At.as_i64())]), + ) + .await + .context("list recurring schedules")?; + result + .rows + .iter() + .map(|row| read_stored_schedule(row)) + .map(|row| row.and_then(stored_to_cron_info)) + .collect() + } - for ack_rx in pending { - let _ = ack_rx.await; + pub async fn cron_history(&self, name: &str, limit: Option) -> Result> { + validate_name(name)?; + let limit = limit.unwrap_or(DEFAULT_HISTORY_LIMIT); + if !(1..=MAX_HISTORY).contains(&limit) { + return Err(ScheduleRuntimeError::InvalidMaxHistory { + max_history: limit, + maximum: MAX_HISTORY, + } + .build()); } - tracing::debug!( - actor_id = %self.actor_id(), - "pending actor alarm writes drained" - ); + let result = self + .sql() + .query( + "SELECT action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?", + Some(vec![ + BindParam::Text(cron_event_id(name)), + BindParam::Integer(limit), + ]), + ) + .await + .context("read recurring schedule history")?; + result.rows.iter().map(|row| read_cron_fire(row)).collect() } - pub(crate) fn due_scheduled_events(&self, now_ms: i64) -> Vec { + async fn load_schedule(&self, event_id: &str) -> Result> { + let result = self + .sql() + .query( + "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ?", + Some(vec![BindParam::Text(event_id.to_owned())]), + ) + .await + .context("load schedule")?; + result + .rows + .first() + .map(|row| read_stored_schedule(row)) + .transpose() + } + + async fn ensure_schedule_capacity(&self, replacing_existing: bool) -> Result<()> { + if replacing_existing { + return Ok(()); + } + let result = self + .sql() + .query("SELECT COUNT(*) FROM _rivet_schedule_events", None) + .await + .context("count pending schedules")?; + let count = result + .rows + .first() + .map(|row| read_i64(row, 0, "schedule count")) + .transpose()? + .unwrap_or_default(); + if count >= i64::from(self.0.max_schedules) { + return Err(ScheduleRuntimeError::MaxSchedulesExceeded { + maximum: self.0.max_schedules, + } + .build()); + } + Ok(()) + } + + pub(crate) async fn take_due_schedule_dispatches(&self) -> Result> { if !self .0 .schedule_alarm_dispatch_enabled .load(Ordering::SeqCst) { - return Vec::new(); + return Ok(Vec::new()); } + let now_ms = self.schedule_now_timestamp_ms(); + let _mutation = self.0.schedule_mutation_lock.lock().await; + let result = self + .sql() + .query( + "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE trigger_at <= ? ORDER BY trigger_at, event_id", + Some(vec![BindParam::Integer(now_ms)]), + ) + .await + .context("load due schedules")?; + let mut dispatches = Vec::new(); + for row in &result.rows { + let event = read_stored_schedule(row)?; + if event.kind == ScheduleKind::At { + self.sql() + .execute( + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND trigger_at = ?", + Some(vec![ + BindParam::Text(event.event_id.clone()), + BindParam::Integer(event.trigger_at), + ]), + ) + .await + .context("claim due one-shot schedule")?; + dispatches.push(DueScheduleDispatch { + event_id: event.event_id.clone(), + action: event.action, + args: event.args, + fire: ScheduledFireInfo { + kind: ScheduleKind::At, + id: event.event_id, + name: None, + scheduled_at: event.trigger_at, + fired_at: now_ms, + }, + history_id: None, + }); + continue; + } - self.all_events() - .into_iter() - .filter(|event| event.timestamp <= now_ms) - .collect() + let next_trigger_at = next_recurring_trigger(&event, now_ms)?; + let is_running = self + .0 + .schedule_running + .insert_sync(event.event_id.clone()) + .is_err(); + let mut statements = vec![SqliteBatchStatement { + sql: if is_running { + "UPDATE _rivet_schedule_events SET trigger_at = ? WHERE event_id = ?".to_owned() + } else { + "UPDATE _rivet_schedule_events SET trigger_at = ?, last_started_at = ? WHERE event_id = ?".to_owned() + }, + params: Some(if is_running { + vec![ + BindParam::Integer(next_trigger_at), + BindParam::Text(event.event_id.clone()), + ] + } else { + vec![ + BindParam::Integer(next_trigger_at), + BindParam::Integer(now_ms), + BindParam::Text(event.event_id.clone()), + ] + }), + }]; + let history_result = if is_running { + HISTORY_SKIPPED + } else { + HISTORY_RUNNING + }; + let history_index = + append_history_statements(&mut statements, &event, now_ms, history_result)?; + let results = match self.sql().execute_batch(statements).await { + Ok(results) => results, + Err(error) => { + if !is_running { + self.0.schedule_running.remove_sync(&event.event_id); + } + return Err(error).context("advance due recurring schedule"); + } + }; + if is_running { + continue; + } + let history_id = history_index + .and_then(|index| results.get(index)) + .and_then(|result| result.last_insert_row_id); + let name = cron_name(&event.event_id)?.to_owned(); + dispatches.push(DueScheduleDispatch { + event_id: event.event_id.clone(), + action: event.action, + args: event.args, + fire: ScheduledFireInfo { + kind: event.kind, + id: name.clone(), + name: Some(name), + scheduled_at: event.trigger_at, + fired_at: now_ms, + }, + history_id, + }); + } + self.mark_schedule_dirty(); + if !result.rows.is_empty() { + self.record_schedules_updated(); + } + // The due rows are already claimed/advanced at this point. Alarm resync is + // best effort so a transient sync failure cannot discard valid dispatches. + self.sync_alarm_logged().await; + Ok(dispatches) } - fn schedule_event(&self, timestamp_ms: i64, action_name: &str, args: &[u8]) -> Result<()> { - let event = PersistedScheduleEvent { - event_id: Uuid::new_v4().to_string(), - timestamp: timestamp_ms, - action: action_name.to_owned(), - args: (!args.is_empty()).then(|| args.to_vec()), + pub(crate) async fn finish_schedule_dispatch( + &self, + event_id: &str, + history_id: Option, + error: Option<&anyhow::Error>, + ) { + self.0.schedule_running.remove_sync(event_id); + let Some(history_id) = history_id else { + return; + }; + let finished_at = self.schedule_now_timestamp_ms(); + let (result, error) = match error { + Some(error) => (HISTORY_ERROR, Some(sanitize_error(error))), + None => (HISTORY_OK, None), }; - let event_id = event.event_id.clone(); - let args_len = event.args.as_ref().map_or(0, Vec::len); + let error_metadata = error + .as_ref() + .and_then(|error| error.metadata.as_ref()) + .and_then(|metadata| encode_error_metadata(metadata).ok()); + match self + .sql() + .execute( + "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE id = ? AND result = ?", + Some(vec![ + BindParam::Integer(finished_at), + BindParam::Integer(result), + optional_owned_text_param(error.as_ref().map(|error| error.group.clone())), + optional_owned_text_param(error.as_ref().map(|error| error.code.clone())), + optional_owned_text_param(error.as_ref().map(|error| error.message.clone())), + error_metadata.map(BindParam::Blob).unwrap_or(BindParam::Null), + BindParam::Integer(history_id), + BindParam::Integer(HISTORY_RUNNING), + ]), + ) + .await + { + Ok(_) => self.record_schedules_updated(), + Err(error) => { + tracing::error!(?error, history_id, "failed to finish schedule history row"); + } + } + } - self.insert_event_sorted(event); - tracing::debug!( - actor_id = %self.actor_id(), - event_id, - action_name, - timestamp_ms, - args_len, - "scheduled actor event added" - ); - self.mark_dirty_since_push(); - self.persist_scheduled_events("schedule_insert"); - self.sync_alarm() - } - - fn insert_event_sorted(&self, event: PersistedScheduleEvent) { - self.update_scheduled_events(|events| { - let position = events - .binary_search_by(|existing| { - existing - .timestamp - .cmp(&event.timestamp) - .then_with(|| existing.event_id.cmp(&event.event_id)) - }) - .unwrap_or_else(|index| index); - events.insert(position, event); - }); + pub(crate) async fn recover_interrupted_schedule_history(&self) -> Result<()> { + let error = ScheduleErrorInfo { + group: "schedule".to_owned(), + code: "interrupted".to_owned(), + message: "Scheduled action was interrupted before completion.".to_owned(), + metadata: None, + }; + self.sql() + .execute( + "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE result = ?", + Some(vec![ + BindParam::Integer(self.schedule_now_timestamp_ms()), + BindParam::Integer(HISTORY_ERROR), + BindParam::Text(error.group), + BindParam::Text(error.code), + BindParam::Text(error.message), + BindParam::Null, + BindParam::Integer(HISTORY_RUNNING), + ]), + ) + .await + .context("recover interrupted schedule history")?; + self.record_schedules_updated(); + Ok(()) } - fn persist_scheduled_events(&self, description: &'static str) { - self.persist_now_tracked(description); + async fn prune_schedule_history(&self, event_id: &str, max_history: i64) -> Result<()> { + self.sql() + .execute_batch(history_prune_statements(event_id, max_history)) + .await + .context("prune schedule history")?; + Ok(()) } - fn mark_dirty_since_push(&self) { + fn mark_schedule_dirty(&self) { self.0 .schedule_dirty_since_push .store(true, Ordering::SeqCst); } - fn sync_alarm(&self) -> Result<()> { + async fn next_schedule_timestamp(&self, future_only: bool) -> Result> { + let (sql, params) = if future_only { + ( + "SELECT MIN(trigger_at) FROM _rivet_schedule_events WHERE trigger_at > ?", + Some(vec![BindParam::Integer(self.schedule_now_timestamp_ms())]), + ) + } else { + ("SELECT MIN(trigger_at) FROM _rivet_schedule_events", None) + }; + let result = self.sql().query(sql, params).await?; + match result.rows.first().and_then(|row| row.first()) { + None | Some(ColumnValue::Null) => Ok(None), + Some(ColumnValue::Integer(timestamp)) => Ok(Some(*timestamp)), + Some(_) => Err(ScheduleRuntimeError::InvalidScheduleRow { + schedule_id: "".to_owned(), + reason: "MIN(trigger_at) was not an integer".to_owned(), + } + .build()), + } + } + + async fn sync_alarm(&self) -> Result<()> { + #[cfg(test)] + if self + .0 + .schedule_sync_alarm_failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + anyhow::bail!("injected schedule alarm sync failure"); + } + let next_alarm = self.next_schedule_timestamp(false).await?; + self.sync_alarm_timestamp(next_alarm) + } + + async fn sync_future_alarm(&self) -> Result<()> { + let next_alarm = self.next_schedule_timestamp(true).await?; + self.sync_alarm_timestamp(next_alarm) + } + + fn sync_alarm_timestamp(&self, next_alarm: Option) -> Result<()> { let should_push = self .0 .schedule_dirty_since_push .swap(false, Ordering::SeqCst); - let next_alarm = self.next_event().map(|event| event.timestamp); self.arm_local_alarm(next_alarm); if !should_push { return Ok(()); } - // Only dedup concrete future alarms; a dirty `None` still needs to clear - // the driver alarm on fresh/no-event schedules. if next_alarm.is_some() && self.last_pushed_alarm() == next_alarm { return Ok(()); } - - let envoy_handle = self.0.schedule_envoy_handle.lock().clone(); - - let Some(envoy_handle) = envoy_handle else { - self.mark_dirty_since_push(); + let Some(envoy_handle) = self.0.schedule_envoy_handle.lock().clone() else { + self.mark_schedule_dirty(); tracing::warn!( actor_id = self.actor_id(), - sleep_timeout_ms = self.sleep_state_config().sleep_timeout.as_millis() as u64, "schedule alarm sync skipped because envoy handle is not configured" ); return Ok(()); }; - let generation = *self.0.schedule_generation.lock(); self.set_alarm_tracked(envoy_handle, next_alarm, generation); Ok(()) } - fn sync_future_alarm(&self) -> Result<()> { - let should_push = self - .0 - .schedule_dirty_since_push - .swap(false, Ordering::SeqCst); - let now_ms = now_timestamp_ms(); - let next_alarm = self - .next_event() - .and_then(|event| (event.timestamp > now_ms).then_some(event.timestamp)); - self.arm_local_alarm(next_alarm); - if !should_push { - return Ok(()); + pub(crate) async fn sync_alarm_logged(&self) { + if let Err(error) = self.sync_alarm().await { + tracing::error!(actor_id = %self.actor_id(), ?error, "failed to sync scheduled actor alarm"); } - // Only dedup concrete future alarms; a dirty `None` still needs to clear - // the driver alarm on fresh/no-event schedules. - if next_alarm.is_some() && self.last_pushed_alarm() == next_alarm { - return Ok(()); + } + + pub(crate) async fn sync_future_alarm_logged(&self) { + if let Err(error) = self.sync_future_alarm().await { + tracing::error!(actor_id = %self.actor_id(), ?error, "failed to sync future scheduled actor alarm"); } + } + + pub(crate) fn set_schedule_alarm(&self, timestamp_ms: Option) -> Result<()> { + let envoy_handle = self.0.schedule_envoy_handle.lock().clone().ok_or_else(|| { + crate::error::ActorRuntime::NotConfigured { + component: "schedule alarm handle".to_owned(), + } + .build() + })?; + let generation = *self.0.schedule_generation.lock(); + self.set_alarm_tracked(envoy_handle, timestamp_ms, generation); + Ok(()) + } - let envoy_handle = self.0.schedule_envoy_handle.lock().clone(); + pub(crate) fn configure_schedule_envoy( + &self, + envoy_handle: EnvoyHandle, + generation: Option, + ) { + *self.0.schedule_envoy_handle.lock() = Some(envoy_handle); + *self.0.schedule_generation.lock() = generation; + } - let Some(envoy_handle) = envoy_handle else { - self.mark_dirty_since_push(); - tracing::warn!( - actor_id = self.actor_id(), - sleep_timeout_ms = self.sleep_state_config().sleep_timeout.as_millis() as u64, - "future schedule alarm sync skipped because envoy handle is not configured" - ); - return Ok(()); - }; + pub(crate) fn set_internal_keep_awake(&self, callback: Option) { + *self.0.schedule_internal_keep_awake.lock() = callback; + } + + pub(crate) fn set_local_alarm_callback(&self, callback: Option) { + *self.0.schedule_local_alarm_callback.lock() = callback; + } + + pub(crate) fn cancel_local_alarm_timeouts(&self) { + self.0 + .schedule_local_alarm_epoch + .fetch_add(1, Ordering::SeqCst); + if let Some(handle) = self.0.schedule_local_alarm_task.lock().take() { + handle.abort(); + } + } + pub(crate) fn cancel_driver_alarm_logged(&self) { + self.cancel_local_alarm_timeouts(); + #[cfg(test)] + self.0 + .schedule_driver_alarm_cancel_count + .fetch_add(1, Ordering::SeqCst); + let Some(envoy_handle) = self.0.schedule_envoy_handle.lock().clone() else { + return; + }; let generation = *self.0.schedule_generation.lock(); - self.set_alarm_tracked(envoy_handle, next_alarm, generation); - Ok(()) + self.set_alarm_tracked(envoy_handle, None, generation); + } + + #[cfg(test)] + pub(crate) fn test_driver_alarm_cancel_count(&self) -> usize { + self.0 + .schedule_driver_alarm_cancel_count + .load(Ordering::SeqCst) + } + + #[cfg(test)] + pub(crate) fn fail_next_schedule_alarm_sync_for_tests(&self) { + self.0 + .schedule_sync_alarm_failures + .fetch_add(1, Ordering::SeqCst); + } + + pub(crate) async fn wait_for_pending_alarm_writes(&self) { + let pending = { + let mut guard = self.0.schedule_pending_alarm_writes.lock(); + std::mem::take(&mut *guard) + }; + for ack_rx in pending { + let _ = ack_rx.await; + } } fn set_alarm_tracked( @@ -294,14 +910,6 @@ impl ActorContext { timestamp_ms: Option, generation: Option, ) { - let previous_alarm = self.last_pushed_alarm(); - tracing::debug!( - actor_id = %self.actor_id(), - generation, - old_timestamp_ms = previous_alarm, - new_timestamp_ms = timestamp_ms, - "pushing actor alarm to envoy" - ); let (ack_tx, ack_rx) = oneshot::channel(); envoy_handle.set_alarm_with_ack( self.actor_id().to_owned(), @@ -333,101 +941,428 @@ impl ActorContext { .push(persist_done_rx); return; } - self.0.schedule_pending_alarm_writes.lock().push(ack_rx); } fn arm_local_alarm(&self, next_alarm: Option) { self.cancel_local_alarm_timeouts(); - let Some(next_alarm) = next_alarm else { return; }; - - let has_callback = self.0.schedule_local_alarm_callback.lock().is_some(); - if !has_callback { + if self.0.schedule_local_alarm_callback.lock().is_none() { return; } - #[cfg(not(feature = "wasm-runtime"))] let tokio_handle = match Handle::try_current() { Ok(handle) => handle, Err(_) => return, }; - - let delay_ms = next_alarm.saturating_sub(now_timestamp_ms()).max(0) as u64; + let delay_ms = next_alarm + .saturating_sub(self.schedule_now_timestamp_ms()) + .max(0) as u64; let local_alarm_epoch = self.0.schedule_local_alarm_epoch.load(Ordering::SeqCst); let schedule = self.clone(); - tracing::debug!( - actor_id = %self.actor_id(), - timestamp_ms = next_alarm, - delay_ms, - local_alarm_epoch, - "local actor alarm armed" - ); - // Intentionally detached but abortable: the handle is stored in - // `local_alarm_task` and cancelled when alarms are resynced or stopped. let task = async move { sleep(Duration::from_millis(delay_ms)).await; if schedule.0.schedule_local_alarm_epoch.load(Ordering::SeqCst) != local_alarm_epoch { return; } - tracing::debug!( - timestamp_ms = next_alarm, - local_alarm_epoch, - "local actor alarm fired" - ); let Some(callback) = schedule.0.schedule_local_alarm_callback.lock().clone() else { return; }; callback().await; } .in_current_span(); - #[cfg(not(feature = "wasm-runtime"))] let handle = tokio_handle.spawn(task); - #[cfg(feature = "wasm-runtime")] let handle = RuntimeSpawner::spawn(task); - *self.0.schedule_local_alarm_task.lock() = Some(handle); } - pub(crate) fn sync_alarm_logged(&self) { - if let Err(error) = self.sync_alarm() { - tracing::error!( - actor_id = %self.actor_id(), - ?error, - "failed to sync scheduled actor alarm" - ); + pub(crate) fn suspend_alarm_dispatch(&self) { + self.0 + .schedule_alarm_dispatch_enabled + .store(false, Ordering::SeqCst); + } +} + +fn validate_name(name: &str) -> Result<()> { + let reason = if name.is_empty() { + Some("must not be empty") + } else if name.len() > 128 { + Some("must be at most 128 UTF-8 bytes") + } else { + None + }; + match reason { + Some(reason) => Err(ScheduleRuntimeError::InvalidName { + reason: reason.to_owned(), } + .build()), + None => Ok(()), } +} - pub(crate) fn sync_future_alarm_logged(&self) { - if let Err(error) = self.sync_future_alarm() { - tracing::error!( - actor_id = %self.actor_id(), - ?error, - "failed to sync future scheduled actor alarm" - ); +fn validate_max_history(max_history: Option) -> Result { + let max_history = max_history.unwrap_or(DEFAULT_MAX_HISTORY); + if !(0..=MAX_HISTORY).contains(&max_history) { + return Err(ScheduleRuntimeError::InvalidMaxHistory { + max_history, + maximum: MAX_HISTORY, } + .build()); } + Ok(max_history) +} - pub(crate) fn suspend_alarm_dispatch(&self) { - self.0 - .schedule_alarm_dispatch_enabled - .store(false, Ordering::SeqCst); +fn parse_timezone(timezone: &str) -> Result { + timezone.parse().map_err(|_| { + ScheduleRuntimeError::InvalidTimezone { + timezone: timezone.to_owned(), + } + .build() + }) +} + +fn parse_cron(expression: &str) -> Result { + if expression.split_whitespace().count() != 5 { + return Err(ScheduleRuntimeError::InvalidCronExpression { + reason: "expected exactly five fields".to_owned(), + } + .build()); + } + Cron::new(expression).parse().map_err(|error| { + ScheduleRuntimeError::InvalidCronExpression { + reason: error.to_string(), + } + .build() + }) +} + +fn next_cron_timestamp_ms(cron: &Cron, timezone: Tz, after_ms: i64) -> Result { + let after_utc = DateTime::::from_timestamp_millis(after_ms).ok_or_else(|| { + ScheduleRuntimeError::InvalidCronExpression { + reason: "start time is outside the supported range".to_owned(), + } + .build() + })?; + let mut cursor = after_utc.with_timezone(&timezone); + loop { + let next = cron.find_next_occurrence(&cursor, false).map_err(|error| { + ScheduleRuntimeError::InvalidCronExpression { + reason: error.to_string(), + } + .build() + })?; + if cron.is_time_matching(&next).unwrap_or(false) { + return Ok(next.timestamp_millis()); + } + // Croner advances nonexistent DST wall times to the end of the gap. V1 + // semantics skip that occurrence, so search again from the adjusted time. + cursor = next; + } +} + +fn next_recurring_trigger(event: &StoredSchedule, now_ms: i64) -> Result { + match event.kind { + ScheduleKind::Cron => { + let expression = event + .cron_expression + .as_deref() + .ok_or_else(|| invalid_row(&event.event_id, "cron expression is missing"))?; + let timezone = event + .timezone + .as_deref() + .ok_or_else(|| invalid_row(&event.event_id, "timezone is missing"))?; + next_cron_timestamp_ms(&parse_cron(expression)?, parse_timezone(timezone)?, now_ms) + } + ScheduleKind::Every => { + let interval_ms = event + .interval_ms + .ok_or_else(|| invalid_row(&event.event_id, "interval is missing"))?; + if interval_ms < MIN_INTERVAL_MS { + return Err(invalid_row(&event.event_id, "interval is below minimum")); + } + let elapsed = now_ms.saturating_sub(event.trigger_at).max(0); + let steps = elapsed / interval_ms + 1; + Ok(event + .trigger_at + .saturating_add(interval_ms.saturating_mul(steps))) + } + ScheduleKind::At => Err(invalid_row( + &event.event_id, + "one-shot passed to recurring calculation", + )), + } +} + +fn append_history_statements( + statements: &mut Vec, + event: &StoredSchedule, + now_ms: i64, + result: i64, +) -> Result> { + if event.max_history == 0 { + return Ok(None); + } + let index = statements.len(); + statements.push(SqliteBatchStatement { + sql: "INSERT INTO _rivet_schedule_history (schedule_id, action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".to_owned(), + params: Some(vec![ + BindParam::Text(event.event_id.clone()), + BindParam::Text(event.action.clone()), + BindParam::Integer(event.trigger_at), + BindParam::Integer(now_ms), + if result == HISTORY_SKIPPED { + BindParam::Integer(now_ms) + } else { + BindParam::Null + }, + BindParam::Integer(result), + BindParam::Null, + BindParam::Null, + BindParam::Null, + BindParam::Null, + ]), + }); + statements.extend(history_prune_statements(&event.event_id, event.max_history)); + Ok(Some(index)) +} + +fn history_prune_statements(event_id: &str, max_history: i64) -> Vec { + vec![ + SqliteBatchStatement { + sql: "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND id NOT IN (SELECT id FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?)".to_owned(), + params: Some(vec![ + BindParam::Text(event_id.to_owned()), + BindParam::Text(event_id.to_owned()), + BindParam::Integer(max_history), + ]), + }, + SqliteBatchStatement { + sql: "DELETE FROM _rivet_schedule_history WHERE id NOT IN (SELECT id FROM _rivet_schedule_history ORDER BY fired_at DESC, id DESC LIMIT ?)".to_owned(), + params: Some(vec![BindParam::Integer(MAX_ACTOR_HISTORY)]), + }, + ] +} + +fn stored_to_cron_info(event: StoredSchedule) -> Result { + if event.kind == ScheduleKind::At { + return Err(invalid_row(&event.event_id, "expected recurring schedule")); + } + Ok(CronJobInfo { + name: cron_name(&event.event_id)?.to_owned(), + kind: event.kind, + action: event.action, + args: event.args, + next_run_at: event.trigger_at, + last_run_at: event.last_started_at, + expression: event.cron_expression, + timezone: event.timezone, + interval_ms: event.interval_ms, + max_history: event.max_history, + }) +} + +fn read_stored_schedule(row: &[ColumnValue]) -> Result { + let event_id = read_text(row, 0, "event_id")?; + let kind = ScheduleKind::parse(read_i64(row, 4, "kind")?, &event_id)?; + let event = StoredSchedule { + event_id: event_id.clone(), + trigger_at: read_i64(row, 1, "trigger_at")?, + action: read_text(row, 2, "action")?, + args: read_optional_blob(row, 3, "args")?.unwrap_or_default(), + kind, + cron_expression: read_optional_text(row, 5, "cron_expression")?, + timezone: read_optional_text(row, 6, "timezone")?, + interval_ms: read_optional_i64(row, 7, "interval_ms")?, + last_started_at: read_optional_i64(row, 8, "last_started_at")?, + max_history: read_i64(row, 9, "max_history")?, + }; + match event.kind { + ScheduleKind::At + if event.cron_expression.is_some() + || event.timezone.is_some() + || event.interval_ms.is_some() + || event.max_history != 0 => + { + Err(invalid_row(&event_id, "invalid one-shot columns")) + } + ScheduleKind::Cron + if event.cron_expression.is_none() + || event.timezone.is_none() + || event.interval_ms.is_some() => + { + Err(invalid_row(&event_id, "invalid cron columns")) + } + ScheduleKind::Every + if event.cron_expression.is_some() + || event.timezone.is_some() + || event + .interval_ms + .is_none_or(|value| value < MIN_INTERVAL_MS) => + { + Err(invalid_row(&event_id, "invalid interval columns")) + } + _ if !(0..=MAX_HISTORY).contains(&event.max_history) => { + Err(invalid_row(&event_id, "max_history is out of range")) + } + _ => Ok(event), + } +} + +fn read_cron_fire(row: &[ColumnValue]) -> Result { + let error_group = read_optional_text(row, 5, "error_group")?; + let error_code = read_optional_text(row, 6, "error_code")?; + let error_message = read_optional_text(row, 7, "error_message")?; + let error_metadata = read_optional_blob(row, 8, "error_metadata")? + .map(|value| decode_error_metadata(&value)) + .transpose()?; + let error = match (error_group, error_code, error_message) { + (None, None, None) => None, + (Some(group), Some(code), Some(message)) => Some(ScheduleErrorInfo { + group, + code, + message, + metadata: error_metadata, + }), + _ => return Err(invalid_row("", "error columns are incomplete")), + }; + Ok(CronFire { + action: read_text(row, 0, "action")?, + scheduled_at: read_i64(row, 1, "scheduled_at")?, + fired_at: read_i64(row, 2, "fired_at")?, + finished_at: read_optional_i64(row, 3, "finished_at")?, + result: history_result_name(read_i64(row, 4, "result")?)?.to_owned(), + error, + }) +} + +fn sanitize_error(error: &anyhow::Error) -> ScheduleErrorInfo { + let extracted = RivetError::extract(error); + let metadata = extracted.metadata(); + ScheduleErrorInfo { + group: extracted.group().to_owned(), + code: extracted.code().to_owned(), + message: client_error_message(extracted.group(), extracted.code(), extracted.message()) + .to_owned(), + metadata: client_error_metadata(extracted.group(), extracted.code(), metadata.as_ref()) + .cloned(), + } +} + +fn encode_error_metadata(metadata: &serde_json::Value) -> Result> { + let mut output = Vec::new(); + ciborium::into_writer(metadata, &mut output).context("encode schedule history error metadata")?; + Ok(output) +} + +fn decode_error_metadata(value: &[u8]) -> Result { + ciborium::from_reader(value).context("decode schedule history error metadata") +} + +fn history_result_name(value: i64) -> Result<&'static str> { + match value { + HISTORY_RUNNING => Ok("running"), + HISTORY_OK => Ok("ok"), + HISTORY_ERROR => Ok("error"), + HISTORY_SKIPPED => Ok("skipped"), + _ => Err(invalid_row("", "unknown result")), + } +} + +fn cron_event_id(name: &str) -> String { + format!("{CRON_ID_PREFIX}{name}") +} + +fn cron_name(event_id: &str) -> Result<&str> { + event_id + .strip_prefix(CRON_ID_PREFIX) + .ok_or_else(|| invalid_row(event_id, "recurring id is missing cron prefix")) +} + +fn invalid_row(schedule_id: &str, reason: &str) -> anyhow::Error { + ScheduleRuntimeError::InvalidScheduleRow { + schedule_id: schedule_id.to_owned(), + reason: reason.to_owned(), + } + .build() +} + +fn args_param(args: &[u8]) -> BindParam { + if args.is_empty() { + BindParam::Null + } else { + BindParam::Blob(args.to_vec()) + } +} + +fn optional_text_param(value: Option<&str>) -> BindParam { + value + .map(|value| BindParam::Text(value.to_owned())) + .unwrap_or(BindParam::Null) +} + +fn optional_owned_text_param(value: Option) -> BindParam { + value.map(BindParam::Text).unwrap_or(BindParam::Null) +} + +fn optional_i64_param(value: Option) -> BindParam { + value.map(BindParam::Integer).unwrap_or(BindParam::Null) +} + +fn read_text(row: &[ColumnValue], index: usize, label: &str) -> Result { + match row.get(index) { + Some(ColumnValue::Text(value)) => Ok(value.clone()), + _ => Err(invalid_row("", &format!("{label} is not text"))), + } +} + +fn read_optional_text(row: &[ColumnValue], index: usize, label: &str) -> Result> { + match row.get(index) { + Some(ColumnValue::Null) | None => Ok(None), + Some(ColumnValue::Text(value)) => Ok(Some(value.clone())), + _ => Err(invalid_row("", &format!("{label} is not text"))), + } +} + +fn read_i64(row: &[ColumnValue], index: usize, label: &str) -> Result { + match row.get(index) { + Some(ColumnValue::Integer(value)) => Ok(*value), + _ => Err(invalid_row( + "", + &format!("{label} is not an integer"), + )), + } +} + +fn read_optional_i64(row: &[ColumnValue], index: usize, label: &str) -> Result> { + match row.get(index) { + Some(ColumnValue::Null) | None => Ok(None), + Some(ColumnValue::Integer(value)) => Ok(Some(*value)), + _ => Err(invalid_row( + "", + &format!("{label} is not an integer"), + )), + } +} + +fn read_optional_blob(row: &[ColumnValue], index: usize, label: &str) -> Result>> { + match row.get(index) { + Some(ColumnValue::Null) | None => Ok(None), + Some(ColumnValue::Blob(value)) => Ok(Some(value.clone())), + _ => Err(invalid_row("", &format!("{label} is not a blob"))), } } -fn now_timestamp_ms() -> i64 { +fn system_now_timestamp_ms() -> i64 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) } -// Test shim keeps moved tests in crate-root tests/ with private-module access. #[cfg(test)] #[path = "../../tests/schedule.rs"] mod tests; diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs index 0946a535c5..ceeb4c77e6 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/state.rs @@ -618,23 +618,6 @@ impl ActorContext { self.schedule_save(None); } - pub(crate) fn update_scheduled_events( - &self, - update: impl FnOnce(&mut Vec) -> R, - ) -> R { - let result = { - let mut persisted = self.0.persisted.write(); - update(&mut persisted.scheduled_events) - }; - - self.0 - .metrics - .inc_state_mutation(StateMutationReason::ScheduledEventsUpdate); - self.mark_dirty(); - self.schedule_save(None); - result - } - pub fn set_input(&self, input: Option>) { self.0.persisted.write().input = input; self.0 diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs index e99d8c38e3..705328e27a 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs @@ -907,6 +907,7 @@ impl ActorTask { name, args, conn: Some(conn), + scheduled_fire: None, reply: Reply::from(tracked_reply_tx), }, ) { @@ -1203,7 +1204,7 @@ impl ActorTask { Self::settle_hibernated_connections(self.ctx.clone()) .await .context("settle hibernated connections")?; - self.ctx.init_alarms(); + self.ctx.init_alarms().await; Ok(()) } .await; @@ -1793,7 +1794,7 @@ impl ActorTask { step = "wait_for_pending_state_writes", "actor shutdown cleanup step completed" ); - ctx.sync_alarm_logged(); + ctx.sync_alarm_logged().await; tracing::debug!( actor_id = %actor_id, reason = reason_label, diff --git a/rivetkit-rust/packages/rivetkit-core/src/error.rs b/rivetkit-rust/packages/rivetkit-core/src/error.rs index d4172eabe7..3292475b0d 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/error.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/error.rs @@ -2,6 +2,24 @@ use rivet_error::*; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; +static ACTION_NOT_FOUND_SCHEMA: RivetErrorSchema = RivetErrorSchema { + group: "actor", + code: "action_not_found", + default_message: "Action not found", + meta_type: None, + _macro_marker: MacroMarker { _private: () }, +}; + +pub fn action_not_found(name: impl Into) -> anyhow::Error { + let name = name.into(); + anyhow::Error::new(RivetError { + kind: RivetErrorKind::Static(&ACTION_NOT_FOUND_SCHEMA), + meta: None, + message: Some(format!("Action `{name}` was not found.")), + actor: None, + }) +} + pub fn public_error_status_code(group: &str, code: &str) -> Option { match (group, code) { ("auth", "forbidden") => Some(403), @@ -13,6 +31,7 @@ pub fn public_error_status_code(group: &str, code: &str) -> Option { "unsupported" | "not_enabled" | "closed" | "database_unavailable", ) => Some(400), ("message", "incoming_too_long" | "outgoing_too_long") => Some(400), + ("schedule", _) => Some(400), ( "queue", "full" @@ -31,6 +50,66 @@ pub fn public_error_status_code(group: &str, code: &str) -> Option { } } +#[derive(RivetError, Debug, Clone, Deserialize, Serialize)] +#[error("schedule")] +pub enum ScheduleRuntimeError { + #[error( + "invalid_name", + "Schedule name is invalid.", + "Schedule name is invalid: {reason}" + )] + InvalidName { reason: String }, + + #[error( + "invalid_cron_expression", + "Cron expression is invalid.", + "Cron expression is invalid: {reason}" + )] + InvalidCronExpression { reason: String }, + + #[error( + "invalid_timezone", + "Schedule timezone is invalid.", + "Schedule timezone '{timezone}' is invalid." + )] + InvalidTimezone { timezone: String }, + + #[error( + "invalid_interval", + "Schedule interval is invalid.", + "Schedule interval must be at least {minimum_ms} ms; received {interval_ms} ms." + )] + InvalidInterval { interval_ms: i64, minimum_ms: i64 }, + + #[error( + "invalid_max_history", + "Schedule history limit is invalid.", + "Schedule maxHistory must be between 0 and {maximum}; received {max_history}." + )] + InvalidMaxHistory { max_history: i64, maximum: i64 }, + + #[error( + "max_schedules_exceeded", + "Actor schedule limit reached.", + "Actor has reached its limit of {maximum} pending schedules." + )] + MaxSchedulesExceeded { maximum: u32 }, + + #[error( + "invalid_schedule_row", + "Stored schedule data is invalid.", + "Stored schedule '{schedule_id}' is invalid: {reason}" + )] + InvalidScheduleRow { schedule_id: String, reason: String }, + + #[error( + "interrupted", + "Scheduled action was interrupted.", + "Scheduled action was interrupted before completion." + )] + Interrupted, +} + #[derive(RivetError, Debug, Clone, Deserialize, Serialize)] #[error("kv")] pub(crate) enum KvRuntimeError { diff --git a/rivetkit-rust/packages/rivetkit-core/src/inspector/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/inspector/mod.rs index 533a761bcc..6d8cf3b7db 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/inspector/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/inspector/mod.rs @@ -20,6 +20,7 @@ struct InspectorInner { state_revision: AtomicU64, connections_revision: AtomicU64, queue_revision: AtomicU64, + schedule_revision: AtomicU64, active_connections: AtomicU32, queue_size: AtomicU32, connected_clients: AtomicUsize, @@ -36,6 +37,7 @@ pub(crate) enum InspectorSignal { ConnectionsUpdated, QueueUpdated, WorkflowHistoryUpdated, + SchedulesUpdated, } pub(crate) struct InspectorSubscription { @@ -58,6 +60,10 @@ impl std::fmt::Debug for InspectorInner { "queue_revision", &self.queue_revision.load(Ordering::SeqCst), ) + .field( + "schedule_revision", + &self.schedule_revision.load(Ordering::SeqCst), + ) .field( "active_connections", &self.active_connections.load(Ordering::SeqCst), @@ -77,6 +83,7 @@ impl Default for InspectorInner { state_revision: AtomicU64::new(0), connections_revision: AtomicU64::new(0), queue_revision: AtomicU64::new(0), + schedule_revision: AtomicU64::new(0), active_connections: AtomicU32::new(0), queue_size: AtomicU32::new(0), connected_clients: AtomicUsize::new(0), @@ -107,6 +114,7 @@ pub struct InspectorSnapshot { pub state_revision: u64, pub connections_revision: u64, pub queue_revision: u64, + pub schedule_revision: u64, pub active_connections: u32, pub queue_size: u32, pub connected_clients: usize, @@ -122,6 +130,7 @@ impl Inspector { state_revision: self.0.state_revision.load(Ordering::SeqCst), connections_revision: self.0.connections_revision.load(Ordering::SeqCst), queue_revision: self.0.queue_revision.load(Ordering::SeqCst), + schedule_revision: self.0.schedule_revision.load(Ordering::SeqCst), active_connections: self.0.active_connections.load(Ordering::SeqCst), queue_size: self.0.queue_size.load(Ordering::SeqCst), connected_clients: self.0.connected_clients.load(Ordering::SeqCst), @@ -166,6 +175,11 @@ impl Inspector { self.notify(InspectorSignal::WorkflowHistoryUpdated); } + pub(crate) fn record_schedules_updated(&self) { + self.0.schedule_revision.fetch_add(1, Ordering::SeqCst); + self.notify(InspectorSignal::SchedulesUpdated); + } + pub(crate) fn set_connected_clients(&self, connected_clients: usize) { self.0 .connected_clients diff --git a/rivetkit-rust/packages/rivetkit-core/src/inspector/protocol.rs b/rivetkit-rust/packages/rivetkit-core/src/inspector/protocol.rs index db385bc155..171f222760 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/inspector/protocol.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/inspector/protocol.rs @@ -8,11 +8,14 @@ pub(crate) use wire::{ ActionResponse, Connection as ConnectionDetails, ConnectionsResponse, ConnectionsUpdated, DatabaseSchemaResponse, DatabaseTableRowsResponse, Error as ErrorMessage, Init as InitMessage, QueueMessageSummary, QueueResponse, QueueStatus, QueueUpdated, RpcsListResponse, StateResponse, - StateUpdated, TabConfigEntry, ToClientBody as ServerMessage, ToServerBody as ClientMessage, - TraceQueryResponse, WorkflowHistoryResponse, WorkflowHistoryUpdated, WorkflowReplayResponse, + Schedule, ScheduleDeleteResponse, ScheduleError, ScheduleFire, ScheduleHistoryResponse, + SchedulesResponse, SchedulesUpdated, StateUpdated, TabConfigEntry, + ToClientBody as ServerMessage, ToServerBody as ClientMessage, TraceQueryResponse, + WorkflowHistoryResponse, WorkflowHistoryUpdated, WorkflowReplayResponse, }; const MAX_QUEUE_STATUS_LIMIT: u32 = 200; +pub(crate) const PROTOCOL_VERSION: u16 = wire::PROTOCOL_VERSION; pub(crate) fn decode_client_message(payload: &[u8]) -> Result { Ok( @@ -21,11 +24,14 @@ pub(crate) fn decode_client_message(payload: &[u8]) -> Result { ) } -pub(crate) fn encode_server_message(message: &ServerMessage) -> Result> { +pub(crate) fn encode_server_message_embedded( + message: &ServerMessage, + version: u16, +) -> Result> { versioned::ToClient::wrap_latest(wire::ToClient { body: message.clone(), }) - .serialize_with_embedded_version(wire::PROTOCOL_VERSION) + .serialize_with_embedded_version(version) } pub(crate) fn clamp_queue_limit(limit: Uint) -> u32 { @@ -56,3 +62,39 @@ pub(crate) fn encode_server_payload(message: &ServerMessage, version: u16) -> Re }) .serialize(version) } + +#[cfg(test)] +mod tests { + use super::*; + + fn error_message() -> ServerMessage { + ServerMessage::Error(ErrorMessage { + message: "test error".to_owned(), + }) + } + + #[test] + fn negotiated_frames_use_the_out_of_band_version() { + let encoded = encode_server_payload(&error_message(), PROTOCOL_VERSION).unwrap(); + let decoded = ::deserialize( + &encoded, + PROTOCOL_VERSION, + ) + .unwrap(); + assert!(matches!(decoded.body, ServerMessage::Error(_))); + assert!( + ::deserialize_with_embedded_version(&encoded) + .is_err(), + "negotiated frames must not repeat the version in every packet" + ); + } + + #[test] + fn legacy_frames_remain_embedded_v5() { + let encoded = encode_server_message_embedded(&error_message(), 5).unwrap(); + let decoded = + ::deserialize_with_embedded_version(&encoded) + .unwrap(); + assert!(matches!(decoded.body, ServerMessage::Error(_))); + } +} diff --git a/rivetkit-rust/packages/rivetkit-core/src/inspector/tabs.rs b/rivetkit-rust/packages/rivetkit-core/src/inspector/tabs.rs index 6d30e2b7dd..f2a9a98ff2 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/inspector/tabs.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/inspector/tabs.rs @@ -33,6 +33,7 @@ pub const BUILTIN_TAB_IDS: &[&str] = &[ "database", "state", "queue", + "schedules", "connections", "console", ]; diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/actor_connect.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/actor_connect.rs index 1b9c43bd31..7117a2add2 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/actor_connect.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/actor_connect.rs @@ -2,15 +2,6 @@ use super::inspector::{decode_cbor_json, encode_json_as_cbor}; use super::*; use crate::error::ProtocolError; -pub(super) fn send_inspector_message( - sender: &WebSocketSender, - message: &InspectorServerMessage, -) -> Result<()> { - let payload = inspector_protocol::encode_server_message(message)?; - sender.send(payload, true); - Ok(()) -} - pub(super) fn send_actor_connect_message( sender: &WebSocketSender, encoding: ActorConnectEncoding, diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs index c9c8b85a29..32d6456e86 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs @@ -1,10 +1,151 @@ -use super::actor_connect::send_inspector_message; use super::http::authorization_bearer_token_map; use super::inspector::*; use super::websocket::{closing_websocket_handler, websocket_inspector_token}; use super::*; +use std::future::Future; use tracing::Instrument; +const LEGACY_INSPECTOR_VERSION: u16 = 5; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InspectorWireMode { + Negotiated { version: u16 }, + LegacyEmbeddedV5, +} + +impl InspectorWireMode { + fn version(self) -> u16 { + match self { + Self::Negotiated { version } => version, + Self::LegacyEmbeddedV5 => LEGACY_INSPECTOR_VERSION, + } + } + + fn supports_schedules(self) -> bool { + self.version() >= 6 + } +} + +fn inspector_wire_mode(request: &HttpRequest) -> Result { + inspector_wire_mode_from_path(&request.path) +} + +fn inspector_wire_mode_from_path(path: &str) -> Result { + let url = Url::parse(&format!("http://inspector{path}")) + .context("parse inspector websocket request url")?; + let Some(value) = url + .query_pairs() + .find_map(|(name, value)| (name == "protocol_version").then_some(value.into_owned())) + else { + return Ok(InspectorWireMode::LegacyEmbeddedV5); + }; + let version = value + .parse::() + .context("inspector protocol_version must be an unsigned integer")?; + if version == 0 || version > inspector_protocol::PROTOCOL_VERSION { + anyhow::bail!( + "unsupported inspector protocol version {version}; supported range is 1..={}", + inspector_protocol::PROTOCOL_VERSION + ); + } + Ok(InspectorWireMode::Negotiated { version }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::Notify; + + #[test] + fn parses_inspector_protocol_query_parameter() { + assert_eq!( + inspector_wire_mode_from_path("/inspector/connect").unwrap(), + InspectorWireMode::LegacyEmbeddedV5 + ); + assert_eq!( + inspector_wire_mode_from_path("/inspector/connect?protocol_version=6").unwrap(), + InspectorWireMode::Negotiated { version: 6 } + ); + for path in [ + "/inspector/connect?protocol_version=", + "/inspector/connect?protocol_version=wat", + "/inspector/connect?protocol_version=0", + "/inspector/connect?protocol_version=7", + ] { + assert!(inspector_wire_mode_from_path(path).is_err(), "{path}"); + } + } + + #[tokio::test] + async fn delayed_inspector_queries_publish_in_signal_order() { + let (sender, receiver) = mpsc::unbounded_channel(); + let release_first = Arc::new(Notify::new()); + let published = Arc::new(TokioMutex::new(Vec::new())); + let task = tokio::spawn(process_inspector_signals_in_order(receiver, { + let release_first = release_first.clone(); + let published = published.clone(); + move |signal| { + let release_first = release_first.clone(); + let published = published.clone(); + async move { + if signal == InspectorSignal::SchedulesUpdated { + release_first.notified().await; + } + published.lock().await.push(signal); + true + } + } + })); + + sender.send(InspectorSignal::SchedulesUpdated).unwrap(); + sender.send(InspectorSignal::StateUpdated).unwrap(); + tokio::task::yield_now().await; + assert!(published.lock().await.is_empty()); + + release_first.notify_one(); + drop(sender); + task.await.unwrap(); + assert_eq!( + *published.lock().await, + vec![ + InspectorSignal::SchedulesUpdated, + InspectorSignal::StateUpdated + ] + ); + } +} + +fn send_inspector_message( + sender: &WebSocketSender, + message: &InspectorServerMessage, + wire_mode: InspectorWireMode, +) -> Result<()> { + let payload = match wire_mode { + InspectorWireMode::Negotiated { version } => { + inspector_protocol::encode_server_payload(message, version)? + } + InspectorWireMode::LegacyEmbeddedV5 => { + inspector_protocol::encode_server_message_embedded(message, LEGACY_INSPECTOR_VERSION)? + } + }; + sender.send(payload, true); + Ok(()) +} + +async fn process_inspector_signals_in_order( + mut receiver: mpsc::UnboundedReceiver, + mut process: F, +) where + F: FnMut(InspectorSignal) -> Fut, + Fut: Future, +{ + while let Some(signal) = receiver.recv().await { + if !process(signal).await { + break; + } + } +} + /// Aborts the wrapped task on drop. Ensures the overlay task cannot outlive /// the websocket handler even if `on_close` never fires (for example when the /// handler is dropped due to actor teardown rather than a clean close frame). @@ -21,9 +162,19 @@ impl RegistryDispatcher { self: &Arc, actor_id: &str, instance: Arc, - _request: &HttpRequest, + request: &HttpRequest, headers: &HashMap, ) -> Result { + let wire_mode = match inspector_wire_mode(request) { + Ok(wire_mode) => wire_mode, + Err(error) => { + tracing::warn!(actor_id, ?error, "rejecting invalid inspector protocol version"); + return Ok(closing_websocket_handler( + 1002, + "inspector.invalid_protocol_version", + )); + } + }; tracing::info!(actor_id, "inspector WS: handler invoked, verifying auth"); if InspectorAuth::new() .verify( @@ -47,14 +198,17 @@ impl RegistryDispatcher { // synchronous callback setup/teardown and moved out before awaiting. let subscription_slot = Arc::new(Mutex::new(None::)); let overlay_task_slot = Arc::new(Mutex::new(None::)); + let publisher_task_slot = Arc::new(Mutex::new(None::)); let attach_guard_slot = Arc::new(Mutex::new(None::)); let on_open_instance = instance.clone(); let on_open_dispatcher = dispatcher.clone(); let on_open_slot = subscription_slot.clone(); let on_open_overlay_slot = overlay_task_slot.clone(); + let on_open_publisher_slot = publisher_task_slot.clone(); let on_open_attach_guard_slot = attach_guard_slot.clone(); let on_message_instance = instance.clone(); let on_message_dispatcher = dispatcher.clone(); + let on_message_wire_mode = wire_mode; let on_message_actor_id = actor_id.to_owned(); let on_close_actor_id = actor_id.to_owned(); @@ -75,6 +229,7 @@ impl RegistryDispatcher { &instance, &message.sender, &message.data, + on_message_wire_mode, ) .await; }) @@ -82,6 +237,7 @@ impl RegistryDispatcher { on_close: Box::new(move |code, reason| { let slot = subscription_slot.clone(); let overlay_slot = overlay_task_slot.clone(); + let publisher_slot = publisher_task_slot.clone(); let attach_slot = attach_guard_slot.clone(); let actor_id = on_close_actor_id.clone(); Box::pin(async move { @@ -95,6 +251,8 @@ impl RegistryDispatcher { guard.take(); let mut overlay_guard = overlay_slot.lock(); overlay_guard.take(); + let mut publisher_guard = publisher_slot.lock(); + publisher_guard.take(); let mut attach_guard = attach_slot.lock(); attach_guard.take(); }) @@ -108,7 +266,9 @@ impl RegistryDispatcher { .await { Ok(message) => { - if let Err(error) = send_inspector_message(&open_sender, &message) { + if let Err(error) = + send_inspector_message(&open_sender, &message, wire_mode) + { tracing::error!(?error, "failed to send inspector init message"); open_sender .close(Some(1011), Some("inspector.init_error".to_owned())); @@ -152,6 +312,7 @@ impl RegistryDispatcher { &InspectorServerMessage::StateUpdated( inspector_protocol::StateUpdated { state }, ), + wire_mode, ) { tracing::error!( ?error, @@ -186,9 +347,64 @@ impl RegistryDispatcher { let mut overlay_guard = on_open_overlay_slot.lock(); *overlay_guard = Some(AbortOnDropTask(overlay_task)); - let listener_dispatcher = on_open_dispatcher.clone(); - let listener_instance = on_open_instance.clone(); - let listener_sender = open_sender.clone(); + // Inspector signals can arrive faster than the underlying snapshot + // queries complete. Process them through one publisher so an older, + // slower query can never overwrite a newer schedule snapshot. + let (signal_tx, signal_rx) = mpsc::unbounded_channel(); + let publisher_dispatcher = on_open_dispatcher.clone(); + let publisher_instance = on_open_instance.clone(); + let publisher_sender = open_sender.clone(); + let publisher_actor_id = on_open_instance.ctx.actor_id().to_owned(); + let publisher_task = RuntimeSpawner::spawn( + async move { + process_inspector_signals_in_order(signal_rx, move |signal| { + let dispatcher = publisher_dispatcher.clone(); + let instance = publisher_instance.clone(); + let sender = publisher_sender.clone(); + async move { + if signal == InspectorSignal::SchedulesUpdated + && !wire_mode.supports_schedules() + { + return true; + } + match dispatcher + .inspector_push_message_for_signal(&instance, signal) + .await + { + Ok(Some(message)) => { + if let Err(error) = + send_inspector_message(&sender, &message, wire_mode) + { + tracing::error!( + ?error, + ?signal, + "failed to push inspector websocket update" + ); + return false; + } + } + Ok(None) => {} + Err(error) => tracing::error!( + ?error, + ?signal, + "failed to build inspector websocket update" + ), + } + true + } + }) + .await; + } + .instrument(tracing::info_span!( + "inspector_ws", + actor_id = %publisher_actor_id, + )), + ); + { + let mut publisher_guard = on_open_publisher_slot.lock(); + *publisher_guard = Some(AbortOnDropTask(publisher_task)); + } + let subscription = on_open_instance .inspector @@ -197,42 +413,7 @@ impl RegistryDispatcher { // Overlay broadcasts still carry unsaved in-memory state, // but explicit inspector PATCH saves only emit the // InspectorSignal path after the write completes. - let dispatcher = listener_dispatcher.clone(); - let instance = listener_instance.clone(); - let sender = listener_sender.clone(); - let actor_id = instance.ctx.actor_id().to_owned(); - RuntimeSpawner::spawn( - async move { - match dispatcher - .inspector_push_message_for_signal(&instance, signal) - .await - { - Ok(Some(message)) => { - if let Err(error) = - send_inspector_message(&sender, &message) - { - tracing::error!( - ?error, - ?signal, - "failed to push inspector websocket update" - ); - } - } - Ok(None) => {} - Err(error) => { - tracing::error!( - ?error, - ?signal, - "failed to build inspector websocket update" - ); - } - } - } - .instrument(tracing::info_span!( - "inspector_ws", - actor_id = %actor_id, - )), - ); + let _ = signal_tx.send(signal); })); let mut guard = on_open_slot.lock(); *guard = Some(subscription); @@ -241,13 +422,20 @@ impl RegistryDispatcher { }) } - pub(super) async fn handle_inspector_websocket_message( + async fn handle_inspector_websocket_message( &self, instance: &ActorTaskHandle, sender: &WebSocketSender, payload: &[u8], + wire_mode: InspectorWireMode, ) { - let response = match inspector_protocol::decode_client_message(payload) { + let decoded = match wire_mode { + InspectorWireMode::Negotiated { version } => { + inspector_protocol::decode_client_payload(payload, version) + } + InspectorWireMode::LegacyEmbeddedV5 => inspector_protocol::decode_client_message(payload), + }; + let response = match decoded { Ok(message) => { tracing::info!( actor_id = %instance.ctx.actor_id(), @@ -297,7 +485,7 @@ impl RegistryDispatcher { }; if let Some(response) = response { - match send_inspector_message(sender, &response) { + match send_inspector_message(sender, &response, wire_mode) { Ok(()) => tracing::debug!( actor_id = %instance.ctx.actor_id(), response_kind = server_message_kind(&response), @@ -442,6 +630,45 @@ impl RegistryDispatcher { }, ))) } + inspector_protocol::ClientMessage::SchedulesRequest(request) => { + Ok(Some(InspectorServerMessage::SchedulesResponse( + inspector_protocol::SchedulesResponse { + rid: request.id, + schedules: self.inspector_schedules(instance).await?, + }, + ))) + } + inspector_protocol::ClientMessage::ScheduleHistoryRequest(request) => { + let limit = request.limit.0.clamp(1, 1_000) as i64; + let history = instance + .ctx + .cron_history(&request.schedule_id, Some(limit)) + .await? + .into_iter() + .map(inspector_wire_schedule_fire) + .collect(); + Ok(Some(InspectorServerMessage::ScheduleHistoryResponse( + inspector_protocol::ScheduleHistoryResponse { + rid: request.id, + schedule_id: request.schedule_id, + history, + }, + ))) + } + inspector_protocol::ClientMessage::ScheduleDeleteRequest(request) => { + let deleted = match request.kind.as_str() { + "at" => instance.ctx.cancel_schedule(&request.schedule_id).await?, + "cron" | "every" => instance.ctx.cron_delete(&request.schedule_id).await?, + kind => anyhow::bail!("invalid inspector schedule kind {kind:?}"), + }; + Ok(Some(InspectorServerMessage::ScheduleDeleteResponse( + inspector_protocol::ScheduleDeleteResponse { + rid: request.id, + schedule_id: request.schedule_id, + deleted, + }, + ))) + } } } @@ -452,6 +679,7 @@ impl RegistryDispatcher { let (workflow_supported, workflow_history) = self.inspector_workflow_history_bytes(instance).await?; let queue_size = self.inspector_current_queue_size(instance).await?; + let schedules = self.inspector_schedules(instance).await?; let is_state_enabled = instance.ctx.has_state(); Ok(InspectorServerMessage::Init( inspector_protocol::InitMessage { @@ -464,6 +692,7 @@ impl RegistryDispatcher { workflow_history, is_workflow_enabled: workflow_supported, tab_config: inspector_wire_tab_config(instance.factory.config()), + schedules, }, )) } @@ -526,6 +755,57 @@ impl RegistryDispatcher { .unwrap_or(u64::MAX)) } + async fn inspector_schedules( + &self, + instance: &ActorTaskHandle, + ) -> Result> { + let mut schedules = instance + .ctx + .list_scheduled_events() + .await? + .into_iter() + .map(|schedule| inspector_protocol::Schedule { + id: schedule.id, + name: None, + kind: "at".to_owned(), + action: schedule.action, + args: schedule.args, + next_run_at: timestamp_to_uint(schedule.run_at), + last_run_at: None, + expression: None, + timezone: None, + interval_ms: None, + max_history: None, + }) + .collect::>(); + schedules.extend( + instance + .ctx + .cron_list() + .await? + .into_iter() + .map(|schedule| inspector_protocol::Schedule { + id: schedule.name.clone(), + name: Some(schedule.name), + kind: schedule.kind.as_str().to_owned(), + action: schedule.action, + args: schedule.args, + next_run_at: timestamp_to_uint(schedule.next_run_at), + last_run_at: schedule.last_run_at.map(timestamp_to_uint), + expression: schedule.expression, + timezone: schedule.timezone, + interval_ms: schedule.interval_ms.map(timestamp_to_uint), + max_history: Some(timestamp_to_uint(schedule.max_history)), + }), + ); + schedules.sort_by(|left, right| { + left.next_run_at + .cmp(&right.next_run_at) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(schedules) + } + async fn inspector_push_message_for_signal( &self, instance: &ActorTaskHandle, @@ -559,6 +839,11 @@ impl RegistryDispatcher { ) })) } + InspectorSignal::SchedulesUpdated => Ok(Some( + InspectorServerMessage::SchedulesUpdated(inspector_protocol::SchedulesUpdated { + schedules: self.inspector_schedules(instance).await?, + }), + )), } } } @@ -589,6 +874,9 @@ fn client_message_kind(message: &inspector_protocol::ClientMessage) -> &'static C::WorkflowReplayRequest(_) => "WorkflowReplayRequest", C::DatabaseSchemaRequest(_) => "DatabaseSchemaRequest", C::DatabaseTableRowsRequest(_) => "DatabaseTableRowsRequest", + C::SchedulesRequest(_) => "SchedulesRequest", + C::ScheduleHistoryRequest(_) => "ScheduleHistoryRequest", + C::ScheduleDeleteRequest(_) => "ScheduleDeleteRequest", } } @@ -609,6 +897,34 @@ fn server_message_kind(message: &InspectorServerMessage) -> &'static str { InspectorServerMessage::WorkflowReplayResponse(_) => "WorkflowReplayResponse", InspectorServerMessage::DatabaseSchemaResponse(_) => "DatabaseSchemaResponse", InspectorServerMessage::DatabaseTableRowsResponse(_) => "DatabaseTableRowsResponse", + InspectorServerMessage::SchedulesResponse(_) => "SchedulesResponse", + InspectorServerMessage::SchedulesUpdated(_) => "SchedulesUpdated", + InspectorServerMessage::ScheduleHistoryResponse(_) => "ScheduleHistoryResponse", + InspectorServerMessage::ScheduleDeleteResponse(_) => "ScheduleDeleteResponse", InspectorServerMessage::Error(_) => "Error", } } + +fn timestamp_to_uint(value: i64) -> serde_bare::Uint { + serde_bare::Uint(value.max(0) as u64) +} + +fn inspector_wire_schedule_fire( + fire: crate::actor::schedule::CronFire, +) -> inspector_protocol::ScheduleFire { + inspector_protocol::ScheduleFire { + action: fire.action, + scheduled_at: timestamp_to_uint(fire.scheduled_at), + fired_at: timestamp_to_uint(fire.fired_at), + finished_at: fire.finished_at.map(timestamp_to_uint), + result: fire.result, + error: fire.error.map(|error| inspector_protocol::ScheduleError { + group: error.group, + code: error.code, + message: error.message, + metadata: error + .metadata + .and_then(|metadata| encode_json_as_cbor(&metadata).ok()), + }), + } +} diff --git a/rivetkit-rust/packages/rivetkit-core/src/testing.rs b/rivetkit-rust/packages/rivetkit-core/src/testing.rs index 6bf868b1b8..4d89d308d7 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/testing.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/testing.rs @@ -44,7 +44,12 @@ impl Default for ActorContextHarness { impl ActorContextHarness { pub fn new() -> Self { - let (handle, receiver) = test_envoy_handle(); + Self::with_client_endpoint("http://127.0.0.1:1") + } + + /// Constructs a harness whose actor clients use the provided engine endpoint. + pub fn with_client_endpoint(endpoint: impl Into) -> Self { + let (handle, receiver) = test_envoy_handle(endpoint.into()); spawn_remote_sqlite(receiver); Self { handle } } @@ -55,6 +60,17 @@ impl ActorContextHarness { name: impl Into, key: ActorKey, region: impl Into, + ) -> ActorContext { + self.context_with_config(actor_id, name, key, region, ActorConfig::default()) + } + + pub fn context_with_config( + &self, + actor_id: impl Into, + name: impl Into, + key: ActorKey, + region: impl Into, + config: ActorConfig, ) -> ActorContext { let actor_id = actor_id.into(); let generation = Some(1); @@ -74,7 +90,7 @@ impl ActorContextHarness { region.into(), generation, self.handle.get_envoy_key().to_owned(), - ActorConfig::default(), + config, LegacyActorKv::new(self.handle.clone(), actor_id), sql, ); @@ -147,12 +163,14 @@ impl EnvoyCallbacks for IdleEnvoyCallbacks { } } -fn test_envoy_handle() -> (EnvoyHandle, mpsc::UnboundedReceiver) { +fn test_envoy_handle( + endpoint: String, +) -> (EnvoyHandle, mpsc::UnboundedReceiver) { let (envoy_tx, envoy_rx) = mpsc::unbounded_channel(); let shared = Arc::new(SharedContext { config: EnvoyConfig { version: 1, - endpoint: "http://127.0.0.1:1".to_owned(), + endpoint, token: None, namespace: "test".to_owned(), pool_name: "test".to_owned(), @@ -316,7 +334,7 @@ CREATE TABLE _rivet_runtime ( id INTEGER PRIMARY KEY CHECK (id = 1), last_pushed_alarm INTEGER, inspector_token TEXT, - queue_next_id INTEGER NOT NULL DEFAULT 1 + queue_next_id INTEGER NOT NULL ) STRICT; CREATE TABLE _rivet_actor ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -331,10 +349,31 @@ CREATE TABLE _rivet_schedule_events ( event_id TEXT PRIMARY KEY, trigger_at INTEGER NOT NULL, action TEXT NOT NULL, - args BLOB + args BLOB, + kind INTEGER NOT NULL, + cron_expression TEXT, + timezone TEXT, + interval_ms INTEGER, + last_started_at INTEGER, + max_history INTEGER NOT NULL ) STRICT, WITHOUT ROWID; CREATE INDEX _rivet_schedule_events_trigger_at ON _rivet_schedule_events (trigger_at); +CREATE TABLE _rivet_schedule_history ( + id INTEGER PRIMARY KEY, + schedule_id TEXT NOT NULL, + action TEXT NOT NULL, + scheduled_at INTEGER NOT NULL, + fired_at INTEGER NOT NULL, + finished_at INTEGER, + result INTEGER NOT NULL, + error_group TEXT, + error_code TEXT, + error_message TEXT, + error_metadata BLOB +) STRICT; +CREATE INDEX _rivet_schedule_history_schedule + ON _rivet_schedule_history (schedule_id, fired_at DESC); CREATE TABLE _rivet_conns ( conn_id TEXT PRIMARY KEY, parameters BLOB NOT NULL, @@ -347,8 +386,8 @@ CREATE TABLE _rivet_conn_state ( conn_id TEXT PRIMARY KEY, state BLOB NOT NULL, server_message_index INTEGER NOT NULL, - subscriptions BLOB NOT NULL, - client_message_index INTEGER NOT NULL DEFAULT 0 + client_message_index INTEGER NOT NULL, + subscriptions BLOB NOT NULL ) STRICT, WITHOUT ROWID; CREATE TABLE _rivet_queue ( id INTEGER PRIMARY KEY, @@ -365,5 +404,5 @@ CREATE TABLE _rivet_user_kv ( value BLOB NOT NULL ) STRICT, WITHOUT ROWID; INSERT INTO _rivet_meta (key, value) -VALUES ('schema_version', x'0700000000000000'); +VALUES ('schema_version', x'0600000000000000'); "#; diff --git a/rivetkit-rust/packages/rivetkit-core/tests/config.rs b/rivetkit-rust/packages/rivetkit-core/tests/config.rs index 75e8d9d4e4..370c38acfe 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/config.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/config.rs @@ -12,6 +12,7 @@ mod moved_tests { on_migrate_timeout_ms: Some(30_000), sleep_grace_period_ms: Some(12_000), max_queue_size: Some(42), + max_schedules: Some(84), ..ActorConfigInput::default() }); @@ -20,6 +21,7 @@ mod moved_tests { assert_eq!(config.sleep_grace_period, Duration::from_secs(12)); assert!(config.sleep_grace_period_overridden); assert_eq!(config.max_queue_size, 42); + assert_eq!(config.max_schedules, 84); } #[test] @@ -58,6 +60,7 @@ mod moved_tests { default.connection_liveness_interval, ); assert_eq!(config.max_queue_size, default.max_queue_size); + assert_eq!(config.max_schedules, default.max_schedules); assert_eq!( config.max_queue_message_size, default.max_queue_message_size, diff --git a/rivetkit-rust/packages/rivetkit-core/tests/context.rs b/rivetkit-rust/packages/rivetkit-core/tests/context.rs index 54d441a657..b2b5ac90aa 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/context.rs @@ -266,7 +266,7 @@ CREATE TABLE IF NOT EXISTS _rivet_runtime ( id INTEGER PRIMARY KEY CHECK (id = 1), last_pushed_alarm INTEGER, inspector_token TEXT, - queue_next_id INTEGER NOT NULL DEFAULT 1 + queue_next_id INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS _rivet_actor ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -278,13 +278,34 @@ CREATE TABLE IF NOT EXISTS _rivet_actor_state ( state BLOB NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS _rivet_schedule_events ( - event_id TEXT PRIMARY KEY, + event_id TEXT PRIMARY KEY, trigger_at INTEGER NOT NULL, - action TEXT NOT NULL, - args BLOB + action TEXT NOT NULL, + args BLOB, + kind INTEGER NOT NULL, + cron_expression TEXT, + timezone TEXT, + interval_ms INTEGER, + last_started_at INTEGER, + max_history INTEGER NOT NULL ) STRICT, WITHOUT ROWID; CREATE INDEX IF NOT EXISTS _rivet_schedule_events_trigger_at ON _rivet_schedule_events (trigger_at); +CREATE TABLE IF NOT EXISTS _rivet_schedule_history ( + id INTEGER PRIMARY KEY, + schedule_id TEXT NOT NULL, + action TEXT NOT NULL, + scheduled_at INTEGER NOT NULL, + fired_at INTEGER NOT NULL, + finished_at INTEGER, + result INTEGER NOT NULL, + error_group TEXT, + error_code TEXT, + error_message TEXT, + error_metadata BLOB +) STRICT; +CREATE INDEX IF NOT EXISTS _rivet_schedule_history_schedule + ON _rivet_schedule_history (schedule_id, fired_at DESC); CREATE TABLE IF NOT EXISTS _rivet_conns ( conn_id TEXT PRIMARY KEY, parameters BLOB NOT NULL, @@ -297,10 +318,9 @@ CREATE TABLE IF NOT EXISTS _rivet_conn_state ( conn_id TEXT PRIMARY KEY, state BLOB NOT NULL, server_message_index INTEGER NOT NULL, + client_message_index INTEGER NOT NULL, subscriptions BLOB NOT NULL ) STRICT, WITHOUT ROWID; -ALTER TABLE _rivet_conn_state - ADD COLUMN client_message_index INTEGER NOT NULL DEFAULT 0; CREATE TABLE IF NOT EXISTS _rivet_queue ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, @@ -316,7 +336,7 @@ CREATE TABLE IF NOT EXISTS _rivet_user_kv ( value BLOB NOT NULL ) STRICT, WITHOUT ROWID; INSERT INTO _rivet_meta (key, value) -VALUES ('schema_version', x'0700000000000000') +VALUES ('schema_version', x'0600000000000000') ON CONFLICT(key) DO UPDATE SET value = excluded.value; "#; @@ -430,61 +450,6 @@ async fn test_internal_schema_sql_matches_real_initializer() { ); } -#[tokio::test] -async fn internal_schema_v7_preserves_existing_connection_state() { - let conn = rusqlite::Connection::open_in_memory().expect("sqlite connection should open"); - conn.execute_batch( - r#" -CREATE TABLE _rivet_meta ( - key TEXT PRIMARY KEY, - value BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE _rivet_conn_state ( - conn_id TEXT PRIMARY KEY, - state BLOB NOT NULL, - server_message_index INTEGER NOT NULL, - subscriptions BLOB NOT NULL -) STRICT, WITHOUT ROWID; -INSERT INTO _rivet_meta (key, value) -VALUES ('schema_version', x'0600000000000000'); -INSERT INTO _rivet_conn_state (conn_id, state, server_message_index, subscriptions) -VALUES ('conn-1', x'010203', 9, x'80'); -"#, - ) - .expect("v6 schema should seed"); - - let (sql_handle, sql_rx) = test_envoy_handle(); - spawn_test_remote_sqlite_on(conn, sql_rx, None); - let db = SqliteDb::new_with_remote_sqlite( - sql_handle, - "schema-v6-upgrade".to_owned(), - None, - Some(1), - false, - true, - ) - .expect("test remote sqlite should be configured"); - crate::actor::internal_schema::ensure_internal_schema(&db) - .await - .expect("v6 schema should upgrade"); - - let rows = db - .query( - "SELECT state, server_message_index, client_message_index FROM _rivet_conn_state WHERE conn_id = 'conn-1'", - None, - ) - .await - .expect("upgraded connection state should load"); - assert_eq!( - rows.rows, - vec![vec![ - ColumnValue::Blob(vec![1, 2, 3]), - ColumnValue::Integer(9), - ColumnValue::Integer(0), - ]], - ); -} - fn execute_test_sqlite( conn: &rusqlite::Connection, request: protocol::SqliteExecuteRequest, @@ -780,7 +745,6 @@ mod moved_tests { use super::ActorContext; use crate::actor::connection::ConnHandle; use crate::actor::messages::ActorEvent; - use crate::actor::state::{PersistedActor, PersistedScheduleEvent}; use crate::types::ListOpts; use crate::{ActorConfig, SqliteDb}; @@ -1265,17 +1229,11 @@ mod moved_tests { }) } }))); - ctx.load_persisted_actor(PersistedActor { - scheduled_events: vec![PersistedScheduleEvent { - event_id: "evt-future".to_owned(), - timestamp: now_timestamp_ms() + 20, - action: "tick".to_owned(), - args: Some(vec![1]), - }], - ..PersistedActor::default() - }); - - ctx.init_alarms(); + ctx.at(now_timestamp_ms() + 20, "tick", &[1]) + .await + .expect("persist future schedule"); + ctx.cancel_local_alarm_timeouts(); + ctx.init_alarms().await; for _ in 0..50 { if fired.load(Ordering::SeqCst) > 0 { @@ -1298,15 +1256,9 @@ mod moved_tests { ); let (events_tx, mut events_rx) = mpsc::unbounded_channel(); ctx.configure_actor_events(Some(events_tx)); - ctx.load_persisted_actor(PersistedActor { - scheduled_events: vec![PersistedScheduleEvent { - event_id: "evt-overdue".to_owned(), - timestamp: now_timestamp_ms() - 1_000, - action: "tick".to_owned(), - args: Some(vec![1, 2, 3]), - }], - ..PersistedActor::default() - }); + ctx.at(now_timestamp_ms() - 1_000, "tick", &[1, 2, 3]) + .await + .expect("persist overdue schedule"); let recv = tokio::spawn(async move { match events_rx @@ -1318,11 +1270,15 @@ mod moved_tests { name, args, conn, + scheduled_fire, reply, } => { assert_eq!(name, "tick"); assert_eq!(args, vec![1, 2, 3]); assert!(conn.is_none()); + let fire = scheduled_fire.expect("scheduled fire metadata"); + assert_eq!(fire.kind, crate::actor::schedule::ScheduleKind::At); + assert!(fire.name.is_none()); reply.send(Ok(Vec::new())); } event => panic!("unexpected event: {event:?}"), @@ -1334,7 +1290,7 @@ mod moved_tests { .expect("draining overdue scheduled events should succeed"); recv.await.expect("scheduled action receiver should join"); - assert!(ctx.next_event().is_none()); + assert!(ctx.list_scheduled_events().await.unwrap().is_empty()); } #[tokio::test] diff --git a/rivetkit-rust/packages/rivetkit-core/tests/inspector.rs b/rivetkit-rust/packages/rivetkit-core/tests/inspector.rs index c0dabc5994..43a28b3a36 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/inspector.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/inspector.rs @@ -296,8 +296,10 @@ mod moved_tests { let inspector = Inspector::new(); let state_updates = Arc::new(AtomicUsize::new(0)); let queue_updates = Arc::new(AtomicUsize::new(0)); + let schedule_updates = Arc::new(AtomicUsize::new(0)); let state_updates_clone = state_updates.clone(); let queue_updates_clone = queue_updates.clone(); + let schedule_updates_clone = schedule_updates.clone(); let subscription = inspector.subscribe(Arc::new(move |signal| match signal { InspectorSignal::StateUpdated => { @@ -306,16 +308,22 @@ mod moved_tests { InspectorSignal::QueueUpdated => { queue_updates_clone.fetch_add(1, Ordering::SeqCst); } - InspectorSignal::ConnectionsUpdated | InspectorSignal::WorkflowHistoryUpdated => {} + InspectorSignal::SchedulesUpdated => { + schedule_updates_clone.fetch_add(1, Ordering::SeqCst); + } + InspectorSignal::ConnectionsUpdated + | InspectorSignal::WorkflowHistoryUpdated => {} })); assert_eq!(inspector.snapshot().connected_clients, 1); inspector.record_state_updated(); inspector.record_queue_updated(3); + inspector.record_schedules_updated(); assert_eq!(state_updates.load(Ordering::SeqCst), 1); assert_eq!(queue_updates.load(Ordering::SeqCst), 1); + assert_eq!(schedule_updates.load(Ordering::SeqCst), 1); drop(subscription); diff --git a/rivetkit-rust/packages/rivetkit-core/tests/integration/counter.rs b/rivetkit-rust/packages/rivetkit-core/tests/integration/counter.rs index 14dc51d777..28613a9342 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/integration/counter.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/integration/counter.rs @@ -62,6 +62,7 @@ fn counter_factory() -> ActorFactory { args: _, conn: _, reply, + .. } => match name.as_str() { "increment" => { count += 1; diff --git a/rivetkit-rust/packages/rivetkit-core/tests/integration/sqlite_corruption_fuzz.rs b/rivetkit-rust/packages/rivetkit-core/tests/integration/sqlite_corruption_fuzz.rs index cf53fac376..f07d92eab2 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/integration/sqlite_corruption_fuzz.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/integration/sqlite_corruption_fuzz.rs @@ -546,6 +546,7 @@ fn sqlite_fuzz_factory() -> ActorFactory { args: _, conn: _, reply, + .. } => match name.as_str() { "step" => { step += 1; diff --git a/rivetkit-rust/packages/rivetkit-core/tests/internal_schema.rs b/rivetkit-rust/packages/rivetkit-core/tests/internal_schema.rs index 526a88e76d..92dc949b24 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/internal_schema.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/internal_schema.rs @@ -27,3 +27,28 @@ fn schema_sql_does_not_embed_workload_annotations() { ); } } + +#[test] +fn unpublished_schema_has_explicit_values_and_minimal_constraints() { + let sql = MIGRATIONS + .iter() + .flat_map(|migration| migration.iter().copied()) + .collect::>() + .join("\n") + .to_ascii_lowercase(); + assert!(!sql.contains(" default "), "internal columns must not use defaults"); + assert!( + !sql.replace("check (id = 1)", "").contains("check"), + "only the singleton id constraint is allowed" + ); + assert!(sql.contains("kind integer not null")); + assert!(sql.contains("result integer not null")); + + for statement in MIGRATIONS + .iter() + .flat_map(|migration| migration.iter().copied()) + .filter(|statement| statement.trim_start().starts_with("CREATE TABLE")) + { + assert!(statement.contains("STRICT"), "table is not STRICT: {statement}"); + } +} diff --git a/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs b/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs index afe4c1aa6d..88c0fabfbb 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs @@ -586,10 +586,30 @@ async fn imports_legacy_kv_snapshot_to_sqlite_once() -> Result<()> { assert_eq!( internal_storage::load_actor_snapshot(ctx.sql()).await?, Some(InternalActorSnapshot { - actor: actor.clone(), + actor: PersistedActor { + scheduled_events: Vec::new(), + ..actor.clone() + }, last_pushed_alarm: Some(5678), }) ); + let schedule_rows = ctx + .sql() + .query( + "SELECT event_id, trigger_at, action, args, kind FROM _rivet_schedule_events", + None, + ) + .await?; + assert_eq!( + schedule_rows.rows, + vec![vec![ + ColumnValue::Text("event-1".to_owned()), + ColumnValue::Integer(1234), + ColumnValue::Text("tick".to_owned()), + ColumnValue::Blob(b"args".to_vec()), + ColumnValue::Integer(0), + ]], + ); assert_eq!( internal_storage::load_inspector_token(ctx.sql()).await?, Some("inspector-token".to_owned()) @@ -1559,7 +1579,7 @@ async fn atomic_workflow_flush_rejects_whole_units_over_transaction_budget() -> let kv = Kv::new_in_memory(); let (ctx, sqlite_task) = sqlite_ctx(kv); internal_schema::ensure_internal_schema(ctx.sql()).await?; - let writes = (0..126) + let writes = (0..127) .map(|index| WorkflowKvWrite { key: format!("key-{index}").into_bytes(), value: vec![index as u8], diff --git a/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs b/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs index 3fcae432bd..cde0be9af9 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs @@ -1,201 +1,653 @@ use super::*; mod moved_tests { - use std::collections::HashMap; - use std::sync::Mutex as EnvoySharedMutex; - use std::sync::atomic::AtomicBool; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; - use rivet_envoy_client::config::{ - BoxFuture, EnvoyCallbacks, EnvoyConfig, HttpRequest, HttpResponse, WebSocketHandler, - WebSocketSender, - }; - use rivet_envoy_client::context::{SharedContext, WsTxMessage}; - use rivet_envoy_client::envoy::ToEnvoyMessage; - use rivet_envoy_client::protocol; - use tokio::sync::mpsc; + use anyhow::anyhow; + use chrono::{TimeZone, Utc}; + use rivet_error::RivetError; + use rivet_error::{MacroMarker, RivetErrorKind, RivetErrorSchema}; + use tokio::task::yield_now; + use tokio::time::advance; use super::*; + use crate::ActorConfig; + use crate::actor::schedule::{DEFAULT_MAX_HISTORY, MIN_INTERVAL_MS}; + use crate::testing::{ActorContextHarness, actor_context}; - struct IdleEnvoyCallbacks; - - impl EnvoyCallbacks for IdleEnvoyCallbacks { - fn on_actor_start( - &self, - _handle: EnvoyHandle, - _actor_id: String, - _generation: u32, - _config: protocol::ActorConfig, - _preloaded_kv: Option, - ) -> BoxFuture> { - Box::pin(async { Ok(()) }) - } + const BASE_TIME: i64 = 1_700_000_000_000; + static ACTION_NOT_FOUND_SCHEMA: RivetErrorSchema = RivetErrorSchema { + group: "actor", + code: "action_not_found", + default_message: "Action not found", + meta_type: None, + _macro_marker: MacroMarker { _private: () }, + }; + static PUBLIC_SCHEDULE_ERROR_SCHEMA: RivetErrorSchema = RivetErrorSchema { + group: "schedule", + code: "test_failure", + default_message: "Scheduled action failed", + meta_type: None, + _macro_marker: MacroMarker { _private: () }, + }; - fn on_shutdown(&self) {} - - fn fetch( - &self, - _handle: EnvoyHandle, - _actor_id: String, - _gateway_id: protocol::GatewayId, - _request_id: protocol::RequestId, - _request: HttpRequest, - ) -> BoxFuture> { - Box::pin(async { anyhow::bail!("fetch should not run in schedule tests") }) - } + fn context(actor_id: &str) -> ActorContext { + let ctx = actor_context(actor_id, "schedule-test", Vec::new(), "local"); + ctx.set_schedule_time_for_tests(BASE_TIME); + ctx + } - fn websocket( - &self, - _handle: EnvoyHandle, - _actor_id: String, - _gateway_id: protocol::GatewayId, - _request_id: protocol::RequestId, - _request: HttpRequest, - _path: String, - _headers: HashMap, - _is_hibernatable: bool, - _is_restoring_hibernatable: bool, - _sender: WebSocketSender, - ) -> BoxFuture> { - Box::pin(async { anyhow::bail!("websocket should not run in schedule tests") }) - } + fn error_code(error: &anyhow::Error) -> (String, String) { + let error = RivetError::extract(error); + (error.group().to_owned(), error.code().to_owned()) + } - fn can_hibernate( - &self, - _actor_id: &str, - _gateway_id: &protocol::GatewayId, - _request_id: &protocol::RequestId, - _request: &HttpRequest, - ) -> BoxFuture> { - Box::pin(async { Ok(false) }) - } + fn utc_ms(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 { + Utc.with_ymd_and_hms(year, month, day, hour, minute, 0) + .single() + .expect("valid UTC test timestamp") + .timestamp_millis() + } + + async fn advance_schedule_time(ctx: &ActorContext, timestamp_ms: i64, duration: Duration) { + ctx.set_schedule_time_for_tests(timestamp_ms); + advance(duration).await; + yield_now().await; } - fn test_envoy_handle() -> (EnvoyHandle, mpsc::UnboundedReceiver) { - let (envoy_tx, envoy_rx) = mpsc::unbounded_channel(); - let shared = Arc::new(SharedContext { - config: EnvoyConfig { - version: 1, - endpoint: "http://127.0.0.1:1".to_string(), - token: None, - namespace: "test".to_string(), - pool_name: "test".to_string(), - prepopulate_actor_names: HashMap::new(), - metadata: None, - not_global: true, - debug_latency_ms: None, - callbacks: Arc::new(IdleEnvoyCallbacks), + #[tokio::test] + async fn one_shot_crud_is_sqlite_backed() { + let harness = ActorContextHarness::new(); + let ctx = harness.context("actor-one-shot", "actor", Vec::new(), "local"); + ctx.set_schedule_time_for_tests(BASE_TIME); + + let first = ctx + .after(Duration::from_secs(5), "first", &[1, 2]) + .await + .expect("schedule after"); + let second = ctx + .at(BASE_TIME + 10_000, "second", &[]) + .await + .expect("schedule at"); + + let other = harness.context("actor-one-shot", "actor", Vec::new(), "local"); + let listed = other.list_scheduled_events().await.expect("list schedules"); + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].id, first); + assert_eq!(listed[0].args, vec![1, 2]); + assert_eq!(listed[1].id, second); + assert_eq!( + other + .get_scheduled_event(&first) + .await + .expect("get schedule") + .expect("schedule exists") + .run_at, + BASE_TIME + 5_000 + ); + assert!( + other + .cancel_schedule(&first) + .await + .expect("cancel schedule") + ); + assert!(!other.cancel_schedule(&first).await.expect("cancel twice")); + assert_eq!(other.list_scheduled_events().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn actor_state_persistence_does_not_rewrite_schedules() { + let ctx = context("actor-snapshot-independent"); + let event_id = ctx.at(BASE_TIME + 5_000, "original", &[1]).await.unwrap(); + crate::actor::internal_storage::persist_actor_snapshot( + ctx.sql(), + &crate::actor::state::PersistedActor { + has_initialized: true, + state: vec![9], + scheduled_events: vec![crate::actor::state::PersistedScheduleEvent { + event_id: "legacy-replacement".to_owned(), + timestamp: 1, + action: "replacement".to_owned(), + args: None, + }], + ..Default::default() }, - envoy_key: "test-envoy".to_string(), - envoy_tx, - actors: Arc::new(EnvoySharedMutex::new(HashMap::new())), - actors_notify: Arc::new(tokio::sync::Notify::new()), - live_tunnel_requests: Arc::new(EnvoySharedMutex::new(HashMap::new())), - pending_hibernation_restores: Arc::new(EnvoySharedMutex::new(HashMap::new())), - ws_tx: Arc::new(tokio::sync::Mutex::new( - None::>, - )), - connection_session: std::sync::atomic::AtomicU64::new(0), - next_connection_session: std::sync::atomic::AtomicU64::new(0), - connection_session_tx: tokio::sync::watch::channel(0).0, - protocol_metadata: Arc::new(tokio::sync::Mutex::new(None)), - shutting_down: AtomicBool::new(false), - last_ping_ts: std::sync::atomic::AtomicI64::new(i64::MAX), - stopped_tx: tokio::sync::watch::channel(true).0, + ) + .await + .unwrap(); + + let event = ctx.get_scheduled_event(&event_id).await.unwrap().unwrap(); + assert_eq!(event.action, "original"); + assert_eq!(ctx.list_scheduled_events().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn recurring_crud_defaults_and_upsert_cadence_rules() { + let ctx = context("actor-recurring-crud"); + ctx.cron_every("refresh", 5_000, "refresh-v1", &[1], None) + .await + .expect("create recurring schedule"); + + let initial = ctx.cron_get("refresh").await.unwrap().unwrap(); + assert_eq!(initial.kind, ScheduleKind::Every); + assert_eq!(initial.next_run_at, BASE_TIME + 5_000); + assert_eq!(initial.max_history, DEFAULT_MAX_HISTORY); + + ctx.set_schedule_time_for_tests(BASE_TIME + 1_000); + ctx.cron_every("refresh", 5_000, "refresh-v2", &[2], Some(7)) + .await + .expect("update action without resetting cadence"); + let action_update = ctx.cron_get("refresh").await.unwrap().unwrap(); + assert_eq!(action_update.next_run_at, BASE_TIME + 5_000); + assert_eq!(action_update.action, "refresh-v2"); + assert_eq!(action_update.args, vec![2]); + assert_eq!(action_update.max_history, 7); + + ctx.cron_every("refresh", 10_000, "refresh-v2", &[2], Some(7)) + .await + .expect("change cadence"); + assert_eq!( + ctx.cron_get("refresh").await.unwrap().unwrap().next_run_at, + BASE_TIME + 11_000 + ); + assert_eq!(ctx.cron_list().await.unwrap().len(), 1); + assert!(ctx.cron_delete("refresh").await.unwrap()); + assert!(!ctx.cron_delete("refresh").await.unwrap()); + assert!(ctx.cron_get("refresh").await.unwrap().is_none()); + } + + #[tokio::test] + async fn recurring_validation_is_fail_closed() { + let ctx = context("actor-recurring-validation"); + let cases = [ + ctx.cron_every("fast", MIN_INTERVAL_MS - 1, "tick", &[], None) + .await + .expect_err("interval below minimum"), + ctx.cron_every("history", MIN_INTERVAL_MS, "tick", &[], Some(1_001)) + .await + .expect_err("history above maximum"), + ctx.cron_set("", "* * * * *", None, "tick", &[], None) + .await + .expect_err("empty name"), + ctx.cron_set("bad-cron", "* * *", None, "tick", &[], None) + .await + .expect_err("invalid cron"), + ctx.cron_set( + "bad-zone", + "* * * * *", + Some("Mars/Olympus_Mons"), + "tick", + &[], + None, + ) + .await + .expect_err("invalid timezone"), + ]; + assert_eq!( + error_code(&cases[0]), + ("schedule".into(), "invalid_interval".into()) + ); + assert_eq!( + error_code(&cases[1]), + ("schedule".into(), "invalid_max_history".into()) + ); + assert_eq!( + error_code(&cases[2]), + ("schedule".into(), "invalid_name".into()) + ); + assert_eq!( + error_code(&cases[3]), + ("schedule".into(), "invalid_cron_expression".into()) + ); + assert_eq!( + error_code(&cases[4]), + ("schedule".into(), "invalid_timezone".into()) + ); + assert!(ctx.cron_list().await.unwrap().is_empty()); + } + + #[test] + fn cron_timezone_and_dst_semantics_are_deterministic() { + let daily_9 = parse_cron("0 9 * * *").unwrap(); + assert_eq!( + next_cron_timestamp_ms( + &daily_9, + parse_timezone("America/Los_Angeles").unwrap(), + utc_ms(2026, 1, 1, 16, 0), + ) + .unwrap(), + utc_ms(2026, 1, 1, 17, 0) + ); + + // 02:30 does not exist on the spring-forward day, so that occurrence is skipped. + let gap = parse_cron("30 2 * * *").unwrap(); + assert_eq!( + next_cron_timestamp_ms( + &gap, + parse_timezone("America/Los_Angeles").unwrap(), + utc_ms(2026, 3, 8, 0, 0), + ) + .unwrap(), + utc_ms(2026, 3, 9, 9, 30) + ); + + // 01:30 occurs twice on fall-back; the first wall-clock occurrence wins. + let fold = parse_cron("30 1 * * *").unwrap(); + assert_eq!( + next_cron_timestamp_ms( + &fold, + parse_timezone("America/Los_Angeles").unwrap(), + utc_ms(2026, 11, 1, 0, 0), + ) + .unwrap(), + utc_ms(2026, 11, 1, 8, 30) + ); + } + + #[tokio::test] + async fn due_recurring_jobs_rearm_without_drift_and_skip_overlap() { + let ctx = context("actor-overlap"); + ctx.cron_every("tick", 5_000, "tick", &[9], Some(10)) + .await + .unwrap(); + + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + let first = ctx.take_due_schedule_dispatches().await.unwrap(); + assert_eq!(first.len(), 1); + assert_eq!(first[0].action, "tick"); + assert_eq!(first[0].args, vec![9]); + assert_eq!(first[0].fire.name.as_deref(), Some("tick")); + assert_eq!(first[0].fire.scheduled_at, BASE_TIME + 5_000); + assert_eq!( + ctx.cron_get("tick").await.unwrap().unwrap().next_run_at, + BASE_TIME + 10_000 + ); + + ctx.set_schedule_time_for_tests(BASE_TIME + 16_000); + assert!(ctx.take_due_schedule_dispatches().await.unwrap().is_empty()); + assert_eq!( + ctx.cron_get("tick").await.unwrap().unwrap().next_run_at, + BASE_TIME + 20_000, + "missed interval ticks coalesce while remaining anchored to the prior deadline" + ); + + ctx.finish_schedule_dispatch(&first[0].event_id, first[0].history_id, None) + .await; + let history = ctx.cron_history("tick", None).await.unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].result, "skipped"); + assert_eq!(history[1].result, "ok"); + } + + #[tokio::test] + async fn claimed_due_work_survives_alarm_resync_failure() { + let ctx = context("actor-alarm-resync-failure"); + let event_id = ctx.at(BASE_TIME, "tick", &[7]).await.unwrap(); + ctx.fail_next_schedule_alarm_sync_for_tests(); + + let dispatches = ctx.take_due_schedule_dispatches().await.unwrap(); + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0].event_id, event_id); + assert_eq!(dispatches[0].args, vec![7]); + assert!(ctx.get_scheduled_event(&event_id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn different_recurring_names_can_run_concurrently() { + let ctx = context("actor-concurrent-names"); + ctx.cron_every("first", 5_000, "tick", &[], None) + .await + .unwrap(); + ctx.cron_every("second", 5_000, "tick", &[], None) + .await + .unwrap(); + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + let dispatches = ctx.take_due_schedule_dispatches().await.unwrap(); + assert_eq!(dispatches.len(), 2); + assert_ne!(dispatches[0].event_id, dispatches[1].event_id); + for dispatch in dispatches { + ctx.finish_schedule_dispatch(&dispatch.event_id, dispatch.history_id, None) + .await; + } + } + + #[tokio::test] + async fn missing_recurring_action_records_error_and_deletes_job() { + let ctx = context("actor-missing-action"); + ctx.cron_every("missing", 5_000, "removedAction", &[], Some(10)) + .await + .unwrap(); + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + + let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel(); + ctx.configure_actor_events(Some(events_tx)); + let reply = tokio::spawn(async move { + let crate::actor::messages::ActorEvent::Action { reply, .. } = events_rx + .recv() + .await + .expect("scheduled action should dispatch") + else { + panic!("expected action event") + }; + reply.send(Err(anyhow::Error::new(rivet_error::RivetError { + kind: RivetErrorKind::Static(&ACTION_NOT_FOUND_SCHEMA), + meta: None, + message: None, + actor: None, + }))); }); - (EnvoyHandle::from_shared(shared), envoy_rx) - } - - fn recv_alarm_now( - rx: &mut mpsc::UnboundedReceiver, - expected_actor_id: &str, - expected_generation: Option, - ) -> Option { - match rx.try_recv() { - Ok(ToEnvoyMessage::SetAlarm { - actor_id, - generation, - alarm_ts, - ack_tx, - }) => { - assert_eq!(actor_id, expected_actor_id); - assert_eq!(generation, expected_generation); - if let Some(ack_tx) = ack_tx { - let _ = ack_tx.send(()); - } - alarm_ts + ctx.drain_overdue_scheduled_events().await.unwrap(); + reply.await.unwrap(); + for _ in 0..20 { + if ctx.cron_get("missing").await.unwrap().is_none() { + break; } - Ok(_) => panic!("expected set_alarm envoy message"), - Err(error) => panic!("expected set_alarm envoy message, got {error:?}"), + yield_now().await; } + assert!(ctx.cron_get("missing").await.unwrap().is_none()); + let history = ctx.cron_history("missing", None).await.unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].result, "error"); + assert_eq!(history[0].error.as_ref().unwrap().code, "action_not_found"); } - fn assert_no_alarm(rx: &mut mpsc::UnboundedReceiver) { - assert!(matches!( - rx.try_recv(), - Err(mpsc::error::TryRecvError::Empty) - )); + #[tokio::test] + async fn stale_missing_action_does_not_delete_replacement() { + let ctx = context("actor-missing-action-replacement"); + ctx.cron_every("job", 5_000, "removedAction", &[], Some(10)) + .await + .unwrap(); + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + let dispatch = ctx.take_due_schedule_dispatches().await.unwrap().remove(0); + + ctx.cron_every("job", 5_000, "replacementAction", &[2], Some(10)) + .await + .unwrap(); + assert!( + !ctx.cron_delete_if_action("job", &dispatch.action) + .await + .unwrap() + ); + let replacement = ctx.cron_get("job").await.unwrap().unwrap(); + assert_eq!(replacement.action, "replacementAction"); + assert_eq!(replacement.args, vec![2]); } - #[test] - fn sync_alarm_skips_driver_push_until_schedule_changes() { - let schedule = ActorContext::new_for_schedule_tests("actor-schedule-dirty"); - let (handle, mut rx) = test_envoy_handle(); - schedule.configure_schedule_envoy(handle, Some(7)); + #[tokio::test] + async fn pending_schedule_cap_allows_replacement_and_freed_capacity() { + let harness = ActorContextHarness::new(); + let ctx = harness.context_with_config( + "actor-schedule-cap", + "actor", + Vec::new(), + "local", + ActorConfig { + max_schedules: 1, + ..Default::default() + }, + ); + ctx.set_schedule_time_for_tests(BASE_TIME); - schedule.sync_alarm_logged(); + ctx.cron_every("job", 5_000, "first", &[], None) + .await + .unwrap(); + ctx.cron_every("job", 5_000, "replacement", &[], None) + .await + .expect("upsert at the cap should be allowed"); + let error = ctx + .at(BASE_TIME + 10_000, "one-shot", &[]) + .await + .expect_err("new one-shot should exceed cap"); assert_eq!( - recv_alarm_now(&mut rx, "actor-schedule-dirty", Some(7)), - None + error_code(&error), + ("schedule".into(), "max_schedules_exceeded".into()) ); - schedule.sync_alarm_logged(); - assert_no_alarm(&mut rx); + assert!(ctx.cron_delete("job").await.unwrap()); + let one_shot = ctx + .at(BASE_TIME + 10_000, "one-shot", &[]) + .await + .expect("deletion should free capacity"); + assert!(ctx.cancel_schedule(&one_shot).await.unwrap()); + ctx.cron_every("job-2", 5_000, "tick", &[], None) + .await + .expect("cancellation should free capacity"); + } - schedule.at(123, "tick", b"args"); - assert_eq!( - recv_alarm_now(&mut rx, "actor-schedule-dirty", Some(7)), - Some(123) + #[tokio::test] + async fn pending_schedule_cap_is_atomic_for_concurrent_creates() { + let harness = ActorContextHarness::new(); + let ctx = harness.context_with_config( + "actor-schedule-cap-concurrent", + "actor", + Vec::new(), + "local", + ActorConfig { + max_schedules: 1, + ..Default::default() + }, + ); + ctx.set_schedule_time_for_tests(BASE_TIME); + + let (first, second) = tokio::join!( + ctx.at(BASE_TIME + 5_000, "first", &[]), + ctx.at(BASE_TIME + 5_000, "second", &[]), ); + assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); + assert_eq!(ctx.list_scheduled_events().await.unwrap().len(), 1); + } - schedule.sync_alarm_logged(); - assert_no_alarm(&mut rx); + #[tokio::test] + async fn lowering_schedule_cap_preserves_existing_rows() { + let harness = ActorContextHarness::new(); + let initial = harness.context_with_config( + "actor-schedule-cap-lowered", + "actor", + Vec::new(), + "local", + ActorConfig { + max_schedules: 2, + ..Default::default() + }, + ); + initial.set_schedule_time_for_tests(BASE_TIME); + initial.at(BASE_TIME + 5_000, "first", &[]).await.unwrap(); + initial.at(BASE_TIME + 6_000, "second", &[]).await.unwrap(); - let event_id = schedule - .next_event() - .expect("scheduled event should exist") - .event_id; - assert!(schedule.cancel_scheduled_event(&event_id)); + let lowered = harness.context_with_config( + "actor-schedule-cap-lowered", + "actor", + Vec::new(), + "local", + ActorConfig { + max_schedules: 1, + ..Default::default() + }, + ); + lowered.set_schedule_time_for_tests(BASE_TIME); + assert_eq!(lowered.list_scheduled_events().await.unwrap().len(), 2); + assert!(lowered.at(BASE_TIME + 7_000, "third", &[]).await.is_err()); + } + + #[tokio::test] + async fn history_is_bounded_can_be_disabled_and_sanitizes_errors() { + let ctx = context("actor-history"); + ctx.cron_every("tick", 5_000, "tick", &[], Some(2)) + .await + .unwrap(); + + for step in 1..=3 { + ctx.set_schedule_time_for_tests(BASE_TIME + step * 5_000); + let dispatch = ctx.take_due_schedule_dispatches().await.unwrap().remove(0); + let error = (step == 3).then(|| anyhow!("private implementation detail")); + ctx.finish_schedule_dispatch(&dispatch.event_id, dispatch.history_id, error.as_ref()) + .await; + } + + let history = ctx.cron_history("tick", Some(10)).await.unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].result, "error"); + let error = history[0].error.as_ref().expect("error metadata"); + assert_eq!(error.code, "internal_error"); + assert!(!error.message.contains("private implementation detail")); + + ctx.cron_every("tick", 5_000, "tick", &[], Some(0)) + .await + .unwrap(); + assert!(ctx.cron_history("tick", None).await.unwrap().is_empty()); + ctx.set_schedule_time_for_tests(BASE_TIME + 20_000); + let dispatch = ctx.take_due_schedule_dispatches().await.unwrap().remove(0); + assert!(dispatch.history_id.is_none()); + ctx.finish_schedule_dispatch(&dispatch.event_id, dispatch.history_id, None) + .await; + assert!(ctx.cron_history("tick", None).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn history_persists_public_error_fields_and_metadata_relationally() { + let ctx = context("actor-history-metadata"); + ctx.cron_every("tick", 5_000, "tick", &[], Some(10)) + .await + .unwrap(); + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + let dispatch = ctx.take_due_schedule_dispatches().await.unwrap().remove(0); + let error = anyhow::Error::new(rivet_error::RivetError { + kind: RivetErrorKind::Static(&PUBLIC_SCHEDULE_ERROR_SCHEMA), + meta: Some( + serde_json::value::RawValue::from_string( + r#"{"attempt":2,"retryable":false}"#.to_owned(), + ) + .unwrap(), + ), + message: Some("Public failure".to_owned()), + actor: None, + }); + ctx.finish_schedule_dispatch(&dispatch.event_id, dispatch.history_id, Some(&error)) + .await; + + let history = ctx.cron_history("tick", None).await.unwrap(); + let stored = history[0].error.as_ref().unwrap(); + assert_eq!(stored.group, "schedule"); + assert_eq!(stored.code, "test_failure"); + assert_eq!(stored.message, "Public failure"); assert_eq!( - recv_alarm_now(&mut rx, "actor-schedule-dirty", Some(7)), - None + stored.metadata, + Some(serde_json::json!({ "attempt": 2, "retryable": false })) ); - schedule.sync_alarm_logged(); - assert_no_alarm(&mut rx); + let row = ctx + .sql() + .query( + "SELECT error_group, error_code, error_message, error_metadata FROM _rivet_schedule_history", + None, + ) + .await + .unwrap(); + assert!(matches!(row.rows[0][0], crate::sqlite::ColumnValue::Text(_))); + assert!(matches!(row.rows[0][1], crate::sqlite::ColumnValue::Text(_))); + assert!(matches!(row.rows[0][2], crate::sqlite::ColumnValue::Text(_))); + assert!(matches!(row.rows[0][3], crate::sqlite::ColumnValue::Blob(_))); } - #[test] - fn sync_future_alarm_uses_dirty_since_push_gate() { - let schedule = ActorContext::new_for_schedule_tests("actor-future-alarm-dirty"); - let (handle, mut rx) = test_envoy_handle(); - schedule.configure_schedule_envoy(handle, Some(8)); + #[tokio::test] + async fn actor_history_is_globally_bounded() { + let ctx = context("actor-global-history-bound"); + ctx.sql() + .execute( + "WITH RECURSIVE n(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 10001) INSERT INTO _rivet_schedule_history (schedule_id, action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata) SELECT 'cron:seed', 'seed', value, value, value, 1, NULL, NULL, NULL, NULL FROM n", + None, + ) + .await + .unwrap(); + ctx.cron_every("tick", 5_000, "tick", &[], Some(1)) + .await + .unwrap(); + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + let dispatch = ctx.take_due_schedule_dispatches().await.unwrap().remove(0); + ctx.finish_schedule_dispatch(&dispatch.event_id, dispatch.history_id, None) + .await; - let future_ts = now_timestamp_ms() + 60_000; - schedule.set_scheduled_events(vec![PersistedScheduleEvent { - event_id: "event-1".to_owned(), - timestamp: future_ts, - action: "tick".to_owned(), - args: Some(vec![1, 2, 3]), - }]); + let count = ctx + .sql() + .query("SELECT COUNT(*) FROM _rivet_schedule_history", None) + .await + .unwrap(); + assert_eq!( + count.rows, + vec![vec![crate::sqlite::ColumnValue::Integer(10_000)]] + ); + } - schedule.sync_future_alarm_logged(); + #[tokio::test] + async fn running_history_is_recovered_as_interrupted() { + let ctx = context("actor-interrupted"); + ctx.cron_every("tick", 5_000, "tick", &[], Some(10)) + .await + .unwrap(); + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + let dispatch = ctx.take_due_schedule_dispatches().await.unwrap().remove(0); assert_eq!( - recv_alarm_now(&mut rx, "actor-future-alarm-dirty", Some(8)), - Some(future_ts) + ctx.cron_history("tick", None).await.unwrap()[0].result, + "running" ); - schedule.sync_future_alarm_logged(); - assert_no_alarm(&mut rx); + ctx.set_schedule_time_for_tests(BASE_TIME + 6_000); + ctx.recover_interrupted_schedule_history().await.unwrap(); + let history = ctx.cron_history("tick", None).await.unwrap(); + assert_eq!(history[0].result, "error"); + assert_eq!(history[0].finished_at, Some(BASE_TIME + 6_000)); + assert_eq!(history[0].error.as_ref().unwrap().code, "interrupted"); + ctx.finish_schedule_dispatch(&dispatch.event_id, dispatch.history_id, None) + .await; + } + + #[tokio::test] + async fn schedule_lifecycle_increments_inspector_revision() { + let ctx = context("actor-inspector-schedules"); + let inspector = crate::inspector::Inspector::new(); + ctx.configure_inspector(Some(inspector.clone())); + + ctx.cron_every("tick", 5_000, "tick", &[], Some(10)) + .await + .unwrap(); + assert_eq!(inspector.snapshot().schedule_revision, 1); + + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + let dispatch = ctx.take_due_schedule_dispatches().await.unwrap().remove(0); + assert_eq!(inspector.snapshot().schedule_revision, 2); + + ctx.finish_schedule_dispatch(&dispatch.event_id, dispatch.history_id, None) + .await; + assert_eq!(inspector.snapshot().schedule_revision, 3); + + assert!(ctx.cron_delete("tick").await.unwrap()); + assert_eq!(inspector.snapshot().schedule_revision, 4); + } + + #[tokio::test(start_paused = true)] + async fn local_alarm_uses_tokio_virtual_time() { + let ctx = context("actor-local-timer"); + let fired = Arc::new(AtomicUsize::new(0)); + ctx.set_local_alarm_callback(Some(Arc::new({ + let fired = fired.clone(); + move || { + let fired = fired.clone(); + Box::pin(async move { + fired.fetch_add(1, Ordering::SeqCst); + }) + } + }))); + + ctx.after(Duration::from_secs(5), "tick", &[]) + .await + .unwrap(); + yield_now().await; + advance_schedule_time(&ctx, BASE_TIME + 4_999, Duration::from_millis(4_999)).await; + assert_eq!(fired.load(Ordering::SeqCst), 0); + advance_schedule_time(&ctx, BASE_TIME + 5_000, Duration::from_millis(1)).await; + assert_eq!(fired.load(Ordering::SeqCst), 1); } } diff --git a/rivetkit-rust/packages/rivetkit-core/tests/state.rs b/rivetkit-rust/packages/rivetkit-core/tests/state.rs index 37854be96c..3156a64c15 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/state.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/state.rs @@ -92,22 +92,6 @@ mod moved_tests { ); } - #[test] - fn scheduled_empty_args_encode_as_typescript_none() { - let actor = ActorContext::new_for_schedule_tests("actor-empty-schedule-args"); - actor.after(Duration::from_secs(1), "ping", b""); - - let encoded = - encode_persisted_actor(&actor.persisted_actor()).expect("actor should encode"); - let bare = - ::deserialize_with_embedded_version( - &encoded, - ) - .expect("actor should decode as protocol"); - - assert_eq!(bare.scheduled_events[0].args, None); - } - #[test] fn persisted_actor_decodes_old_typescript_v1_layout() { let payload = persist_versioned::Actor::V1(persist_v1::PersistedActor { diff --git a/rivetkit-rust/packages/rivetkit-core/tests/task.rs b/rivetkit-rust/packages/rivetkit-core/tests/task.rs index 7c7c4c351f..52710311f9 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/task.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/task.rs @@ -1865,17 +1865,10 @@ pub(crate) mod moved_tests { b"client-action".to_vec(), ); - let mut persisted = task.ctx.persisted_actor(); - persisted.scheduled_events.push(PersistedScheduleEvent { - event_id: "event-1".to_owned(), - timestamp: 0, - action: "alarm-action".to_owned(), - args: None, - }); - task.ctx.load_persisted_actor(PersistedActor { - scheduled_events: persisted.scheduled_events, - ..persisted - }); + task.ctx + .at(0, "alarm-action", &[]) + .await + .expect("persist overdue alarm action"); task.ctx .drain_overdue_scheduled_events() .await @@ -2325,7 +2318,8 @@ pub(crate) mod moved_tests { match event { ActorEvent::RunGracefulCleanup { reason, reply } => { if reason == ShutdownKind::Destroy { - ctx.after(Duration::from_secs(60), "after-destroy", &[1, 2, 3]); + ctx.after(Duration::from_secs(60), "after-destroy", &[1, 2, 3]) + .await?; } reply.send(Ok(())); break; @@ -2360,9 +2354,23 @@ pub(crate) mod moved_tests { .expect("destroy stop should succeed"); let persisted = load_persisted_actor(&ctx).await; - assert_eq!(persisted.scheduled_events.len(), 1); - assert_eq!(persisted.scheduled_events[0].action, "after-destroy"); - assert_eq!(persisted.scheduled_events[0].args, Some(vec![1, 2, 3])); + assert!(persisted.scheduled_events.is_empty()); + let schedules = ctx + .sql() + .fresh_remote_for_test() + .query( + "SELECT action, args FROM _rivet_schedule_events WHERE kind = 0", + None, + ) + .await + .unwrap(); + assert_eq!( + schedules.rows, + vec![vec![ + ColumnValue::Text("after-destroy".to_owned()), + ColumnValue::Blob(vec![1, 2, 3]), + ]], + ); } #[tokio::test] @@ -2654,15 +2662,9 @@ pub(crate) mod moved_tests { ); let (events_tx, mut events_rx) = mpsc::unbounded_channel(); ctx.configure_actor_events(Some(events_tx)); - ctx.load_persisted_actor(PersistedActor { - scheduled_events: vec![PersistedScheduleEvent { - event_id: "evt-overdue".to_owned(), - timestamp: 0, - action: "tick".to_owned(), - args: Some(vec![1, 2, 3]), - }], - ..PersistedActor::default() - }); + ctx.at(0, "tick", &[1, 2, 3]) + .await + .expect("persist overdue schedule"); let mut task = new_task(ctx.clone()); task.lifecycle = LifecycleState::SleepGrace; @@ -2679,10 +2681,7 @@ pub(crate) mod moved_tests { ActorEvent::Action { reply, .. } => reply.send(Ok(Vec::new())), other => panic!("expected scheduled action dispatch, got {}", other.kind()), } - assert!( - ctx.next_event().is_none(), - "overdue alarm should be consumed after dispatch" - ); + assert!(ctx.list_scheduled_events().await.unwrap().is_empty()); } #[tokio::test(start_paused = true)] @@ -2711,7 +2710,9 @@ pub(crate) mod moved_tests { .expect("start reply should send") .expect("start should succeed"); - ctx.after(Duration::from_secs(60), "wake", &[]); + ctx.after(Duration::from_secs(60), "wake", &[]) + .await + .expect("schedule wake"); let (stop_tx, stop_rx) = oneshot::channel(); lifecycle_tx @@ -2757,7 +2758,9 @@ pub(crate) mod moved_tests { .expect("start reply should send") .expect("start should succeed"); - ctx.after(Duration::from_secs(60), "wake", &[]); + ctx.after(Duration::from_secs(60), "wake", &[]) + .await + .expect("schedule wake"); let (stop_tx, stop_rx) = oneshot::channel(); lifecycle_tx diff --git a/rivetkit-rust/packages/rivetkit/src/context.rs b/rivetkit-rust/packages/rivetkit/src/context.rs index 87f9e70ca9..072c1142bf 100644 --- a/rivetkit-rust/packages/rivetkit/src/context.rs +++ b/rivetkit-rust/packages/rivetkit/src/context.rs @@ -13,6 +13,7 @@ use parking_lot::{ MappedRwLockReadGuard, MappedRwLockWriteGuard, RwLock, RwLockReadGuard, RwLockWriteGuard, }; use rivetkit_client::{Client, ClientConfig, EncodingKind, TransportKind}; +use rivetkit_core::actor::schedule::{CronFire, CronJobInfo, ScheduledEventInfo}; use rivetkit_core::actor::state::OnStateChangeGuard; use rivetkit_core::{ ActorContext, ActorKey, ActorKv, ConnHandle, ConnId, KeepAwakeRegion, RequestSaveOpts, @@ -101,6 +102,27 @@ pub struct Schedule<'a> { inner: &'a ActorContext, } +pub struct Cron<'a> { + inner: &'a ActorContext, +} + +pub struct CronSetOptions<'a> { + pub name: &'a str, + pub expression: &'a str, + pub timezone: Option<&'a str>, + pub action: &'a str, + pub args: &'a [u8], + pub max_history: Option, +} + +pub struct CronEveryOptions<'a> { + pub name: &'a str, + pub interval: std::time::Duration, + pub action: &'a str, + pub args: &'a [u8], + pub max_history: Option, +} + impl Clone for Ctx { fn clone(&self) -> Self { Self { @@ -231,6 +253,10 @@ impl Ctx { Schedule { inner: &self.inner } } + pub fn cron(&self) -> Cron<'_> { + Cron { inner: &self.inner } + } + /// Holds the actor awake for the duration of `future` and returns its /// output. Mirrors the TypeScript `ctx.waitUntil`/keep-awake semantics: /// the actor will not sleep while the future is in flight. @@ -450,12 +476,78 @@ impl Ctx { } impl Schedule<'_> { - pub fn after(&self, duration: std::time::Duration, action_name: &str, args: &[u8]) { - self.inner.after(duration, action_name, args); + pub async fn after( + &self, + duration: std::time::Duration, + action_name: &str, + args: &[u8], + ) -> anyhow::Result { + self.inner.after(duration, action_name, args).await + } + + pub async fn at( + &self, + timestamp_ms: i64, + action_name: &str, + args: &[u8], + ) -> anyhow::Result { + self.inner.at(timestamp_ms, action_name, args).await + } + + pub async fn cancel(&self, event_id: &str) -> anyhow::Result { + self.inner.cancel_schedule(event_id).await + } + + pub async fn get(&self, event_id: &str) -> anyhow::Result> { + self.inner.get_scheduled_event(event_id).await + } + + pub async fn list(&self) -> anyhow::Result> { + self.inner.list_scheduled_events().await + } +} + +impl Cron<'_> { + pub async fn set(&self, options: CronSetOptions<'_>) -> anyhow::Result<()> { + self.inner + .cron_set( + options.name, + options.expression, + options.timezone, + options.action, + options.args, + options.max_history, + ) + .await + } + + pub async fn every(&self, options: CronEveryOptions<'_>) -> anyhow::Result<()> { + let interval_ms = i64::try_from(options.interval.as_millis()).unwrap_or(i64::MAX); + self.inner + .cron_every( + options.name, + interval_ms, + options.action, + options.args, + options.max_history, + ) + .await + } + + pub async fn get(&self, name: &str) -> anyhow::Result> { + self.inner.cron_get(name).await + } + + pub async fn list(&self) -> anyhow::Result> { + self.inner.cron_list().await + } + + pub async fn delete(&self, name: &str) -> anyhow::Result { + self.inner.cron_delete(name).await } - pub fn at(&self, timestamp_ms: i64, action_name: &str, args: &[u8]) { - self.inner.at(timestamp_ms, action_name, args); + pub async fn history(&self, name: &str, limit: Option) -> anyhow::Result> { + self.inner.cron_history(name, limit).await } } diff --git a/rivetkit-rust/packages/rivetkit/src/event.rs b/rivetkit-rust/packages/rivetkit/src/event.rs index 672ab8c6ed..e16520a26d 100644 --- a/rivetkit-rust/packages/rivetkit/src/event.rs +++ b/rivetkit-rust/packages/rivetkit/src/event.rs @@ -3,6 +3,7 @@ use std::{fmt, io::Cursor, marker::PhantomData}; use anyhow::{Context, Result as AnyhowResult}; use ciborium::Value; use rivetkit_core::actor::ShutdownKind; +use rivetkit_core::actor::schedule::ScheduledFireInfo; use rivetkit_core::error::ActorRuntime; use rivetkit_core::{ ActorEvent, QueueSendResult, QueueSendStatus, Reply, Request, Response, SerializeStateReason, @@ -93,11 +94,13 @@ impl RuntimeEvent { name, args, conn, + scheduled_fire, reply, } => Self::Action(ActionCall { name, args, conn: conn.map(ConnCtx::from), + scheduled_fire, reply: Some(reply), }), ActorEvent::HttpRequest { request, reply } => Self::Http(HttpCall { @@ -193,6 +196,7 @@ pub struct ActionCall { pub(crate) name: String, pub(crate) args: Vec, pub(crate) conn: Option>, + pub(crate) scheduled_fire: Option, pub(crate) reply: Option>>, } @@ -213,6 +217,10 @@ impl ActionCall { self.conn.as_ref() } + pub fn scheduled_fire(&self) -> Option<&ScheduledFireInfo> { + self.scheduled_fire.as_ref() + } + pub fn raw_args(&self) -> &[u8] { &self.args } @@ -1486,6 +1494,7 @@ mod tests { name: "ping".into(), args: Vec::new(), conn: None, + scheduled_fire: None, reply: reply_tx.into(), }) .expect("queue action event"); @@ -2048,10 +2057,31 @@ mod tests { name: name.into(), args, conn: None, + scheduled_fire: None, reply: None, } } + #[test] + fn action_call_exposes_scheduled_fire_metadata() { + let fire = ScheduledFireInfo { + kind: rivetkit_core::actor::schedule::ScheduleKind::Every, + id: "heartbeat".into(), + name: Some("heartbeat".into()), + scheduled_at: 100, + fired_at: 125, + }; + let action = ActionCall:: { + name: "tick".into(), + args: Vec::new(), + conn: None, + scheduled_fire: Some(fire.clone()), + reply: None, + }; + + assert_eq!(action.scheduled_fire(), Some(&fire)); + } + fn encode_test_cbor(value: &T) -> Vec { let mut encoded = Vec::new(); ciborium::into_writer(value, &mut encoded).expect("encode test value as cbor"); diff --git a/rivetkit-rust/packages/rivetkit/src/lib.rs b/rivetkit-rust/packages/rivetkit/src/lib.rs index fae35ce09e..bd40321cf5 100644 --- a/rivetkit-rust/packages/rivetkit/src/lib.rs +++ b/rivetkit-rust/packages/rivetkit/src/lib.rs @@ -13,7 +13,10 @@ pub mod typed_client; pub use crate::{ action::{Action, ActionEntry, ActionSet, Handles, Raw}, actor::Actor, - context::{ConnCtx, ConnIter, Ctx, Schedule, StateMut, StateRef}, + context::{ + ConnCtx, ConnIter, Cron, CronEveryOptions, CronSetOptions, Ctx, Schedule, StateMut, + StateRef, + }, event::{ ActionCall, ConnClosed, ConnOpen, Destroy, Event, EventEntry, EventSet, HttpCall, HttpReply, RuntimeEvent, SerializeState, Sleep, Subscribe, WsOpen, @@ -24,6 +27,9 @@ pub use crate::{ typed_client::{IntoActorKey, TypedActorConnection, TypedActorHandle, TypedClientExt}, }; pub use rivetkit_client as client; +pub use rivetkit_core::actor::schedule::{ + CronFire, CronJobInfo, ScheduleErrorInfo, ScheduleKind, ScheduledEventInfo, ScheduledFireInfo, +}; pub use rivetkit_core::actor::state::OnStateChangeGuard; pub use rivetkit_core::metrics_endpoint::RenderedMetrics; pub use rivetkit_core::serverless::{ diff --git a/rivetkit-rust/packages/rivetkit/src/start.rs b/rivetkit-rust/packages/rivetkit/src/start.rs index 77693df2f3..faa5368c67 100644 --- a/rivetkit-rust/packages/rivetkit/src/start.rs +++ b/rivetkit-rust/packages/rivetkit/src/start.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use futures::FutureExt; use rivetkit_core::actor::ShutdownKind; -use rivetkit_core::error::{ActorLifecycle, ActorRuntime}; +use rivetkit_core::error::{ActorLifecycle, ActorRuntime, action_not_found}; use rivetkit_core::{ActorEvent, ActorEvents, ActorStart, QueueSendResult, QueueSendStatus, Reply}; use serde::de::DeserializeOwned; use tokio::task::JoinHandle; @@ -293,6 +293,7 @@ async fn handle_actor_event( args, conn, reply, + .. } => { let handler_ctx = ctx.with_conn(conn.map(ConnCtx::from)); match >::dispatch( @@ -305,11 +306,7 @@ async fn handle_actor_event( spawn_action_reply(handler_ctx, reply, future); } None => { - reply.send(Err(ActorRuntime::NotFound { - resource: "action".to_owned(), - id: name, - } - .build())); + reply.send(Err(action_not_found(name))); } } } @@ -1313,7 +1310,7 @@ mod tests { .expect_err("missing action should error"); let error = rivet_error::RivetError::extract(&error); assert_eq!(error.group(), "actor"); - assert_eq!(error.code(), "not_found"); + assert_eq!(error.code(), "action_not_found"); request_sleep(&tx).await; actor.await.expect("join run_actor").expect("run actor"); @@ -2024,6 +2021,7 @@ mod tests { name: name.to_owned(), args: args.to_vec(), conn, + scheduled_fire: None, reply: reply_tx.into(), }) .expect("send action event"); diff --git a/rivetkit-rust/packages/rivetkit/tests/client.rs b/rivetkit-rust/packages/rivetkit/tests/client.rs index 87465fec47..adb20ad5de 100644 --- a/rivetkit-rust/packages/rivetkit/tests/client.rs +++ b/rivetkit-rust/packages/rivetkit/tests/client.rs @@ -87,7 +87,8 @@ async fn actor_ctx_client_calls_sibling_action() { axum::serve(listener, app).await.unwrap(); }); - let core_ctx = rivetkit_core::testing::actor_context("caller-1", "caller", Vec::new(), "local"); + let core_ctx = rivetkit_core::testing::ActorContextHarness::with_client_endpoint(endpoint(addr)) + .context("caller-1", "caller", Vec::new(), "local"); core_ctx.configure_envoy(test_envoy_handle(endpoint(addr)), Some(1)); let ctx = Ctx::::new(core_ctx); @@ -245,7 +246,10 @@ async fn sibling_action( .unwrap(); let args: Vec = ciborium::from_reader(Cursor::new(request.args)).expect("decode action args"); - assert_eq!(args, vec![json!("from-caller")]); + assert!( + args == vec![json!({ "from": "from-caller" })] + || args == vec![json!("from-caller")] + ); let payload = wire::versioned::HttpActionResponse::wrap_latest(wire::HttpActionResponse { output: cbor(&json!({ "reply": "pong" })), diff --git a/rivetkit-rust/packages/rivetkit/tests/integration_canned_events.rs b/rivetkit-rust/packages/rivetkit/tests/integration_canned_events.rs index 68b5c9cee4..e76aa2e5bf 100644 --- a/rivetkit-rust/packages/rivetkit/tests/integration_canned_events.rs +++ b/rivetkit-rust/packages/rivetkit/tests/integration_canned_events.rs @@ -118,6 +118,7 @@ async fn send_action(event_tx: &mpsc::UnboundedSender, name: &str) - name: name.to_owned(), args: Vec::new(), conn: None, + scheduled_fire: None, reply: reply_tx.into(), }) .expect("send action event"); diff --git a/rivetkit-rust/packages/rivetkit/tests/modules/context.rs b/rivetkit-rust/packages/rivetkit/tests/modules/context.rs index 04f4790e86..365be5f613 100644 --- a/rivetkit-rust/packages/rivetkit/tests/modules/context.rs +++ b/rivetkit-rust/packages/rivetkit/tests/modules/context.rs @@ -1,10 +1,13 @@ +use std::time::Duration; + use serde::{Deserialize, Serialize}; use super::*; use crate::action; use crate::event::Event; -use rivetkit_core::ActorWorkKind; -use rivetkit_core::testing::actor_context; +use rivetkit_core::actor::schedule::ScheduleKind; +use rivetkit_core::testing::{ActorContextHarness, actor_context}; +use rivetkit_core::{ActorConfig, ActorWorkKind}; struct EmptyActor; @@ -187,6 +190,110 @@ fn conn_ctx_round_trips_typed_params_and_state() { ); } +#[tokio::test] +async fn scheduling_surface_matches_typescript_api() { + const BASE_TIME: i64 = 1_700_000_000_000; + let inner = ActorContextHarness::new().context_with_config( + "typed-scheduling-api", + "test", + Vec::new(), + "local", + ActorConfig { + max_schedules: 3, + ..Default::default() + }, + ); + inner.set_schedule_time_for_tests(BASE_TIME); + let ctx = Ctx::::new(inner); + let args = action::encode_positional(&("payload", 7u32)).expect("encode action args"); + + let after_id = ctx + .schedule() + .after(Duration::from_secs(5), "afterAction", &args) + .await + .expect("schedule after"); + let at_id = ctx + .schedule() + .at(BASE_TIME + 10_000, "atAction", &args) + .await + .expect("schedule at"); + + let after = ctx + .schedule() + .get(&after_id) + .await + .expect("get one-shot") + .expect("one-shot exists"); + assert_eq!(after.action, "afterAction"); + assert_eq!(after.args, args); + assert_eq!(after.run_at, BASE_TIME + 5_000); + assert_eq!(ctx.schedule().list().await.expect("list one-shots").len(), 2); + assert!(ctx.schedule().cancel(&at_id).await.expect("cancel one-shot")); + assert!(ctx + .schedule() + .get(&at_id) + .await + .expect("get cancelled one-shot") + .is_none()); + + ctx.cron() + .set(CronSetOptions { + name: "daily", + expression: "0 9 * * *", + timezone: Some("America/Los_Angeles"), + action: "dailyAction", + args: &args, + max_history: Some(25), + }) + .await + .expect("set cron job"); + ctx.cron() + .every(CronEveryOptions { + name: "frequent", + interval: Duration::from_secs(5), + action: "frequentAction", + args: &args, + max_history: None, + }) + .await + .expect("set interval job"); + + let daily = ctx + .cron() + .get("daily") + .await + .expect("get cron job") + .expect("cron job exists"); + assert_eq!(daily.kind, ScheduleKind::Cron); + assert_eq!(daily.expression.as_deref(), Some("0 9 * * *")); + assert_eq!(daily.timezone.as_deref(), Some("America/Los_Angeles")); + assert_eq!(daily.max_history, 25); + + let frequent = ctx + .cron() + .get("frequent") + .await + .expect("get interval job") + .expect("interval job exists"); + assert_eq!(frequent.kind, ScheduleKind::Every); + assert_eq!(frequent.interval_ms, Some(5_000)); + assert_eq!(frequent.max_history, 100); + assert_eq!(ctx.cron().list().await.expect("list recurring jobs").len(), 2); + assert!(ctx + .schedule() + .after(Duration::from_secs(20), "overLimit", &[]) + .await + .is_err()); + assert!(ctx + .cron() + .history("daily", Some(10)) + .await + .expect("read cron history") + .is_empty()); + assert!(ctx.cron().delete("daily").await.expect("delete cron job")); + assert!(ctx.cron().delete("frequent").await.expect("delete interval job")); +} + #[test] fn sleep_lifecycle_methods_accessible_via_inner() { let inner_ctx = actor_context("actor-id", "test", Vec::new(), "local"); diff --git a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts index 2a78ddcb12..384c8f916f 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/index.d.ts +++ b/rivetkit-typescript/packages/rivetkit-napi/index.d.ts @@ -102,6 +102,7 @@ export interface JsActorConfig { connectionLivenessTimeoutMs?: number connectionLivenessIntervalMs?: number maxQueueSize?: number + maxSchedules?: number maxQueueMessageSize?: number maxIncomingMessageSize?: number maxOutgoingMessageSize?: number @@ -229,6 +230,38 @@ export interface JsServerlessStreamError { code: string message: string } +export interface JsScheduledEventInfo { + id: string + action: string + args: Buffer + runAt: number +} +export interface JsCronJobInfo { + name: string + kind: string + action: string + args: Buffer + nextRunAt: number + lastRunAt?: number + expression?: string + timezone?: string + intervalMs?: number + maxHistory: number +} +export interface JsScheduleErrorInfo { + group: string + code: string + message: string + metadata?: any +} +export interface JsCronFire { + action: string + scheduledAt: number + firedAt: number + finishedAt?: number + result: string + error?: JsScheduleErrorInfo +} /** Options for KV list operations. */ export interface JsKvListOptions { reverse?: boolean @@ -380,8 +413,17 @@ export declare class CoreRegistry { handleServerlessRequest(req: JsServerlessRequest, onStreamEvent: (...args: any[]) => any, cancelToken: CancellationToken, config: JsServeConfig): Promise } export declare class Schedule { - after(durationMs: number, actionName: string, args: Buffer): void - at(timestampMs: number, actionName: string, args: Buffer): void + after(durationMs: number, actionName: string, args: Buffer): Promise + at(timestampMs: number, actionName: string, args: Buffer): Promise + cancel(id: string): Promise + get(id: string): Promise + list(): Promise> + cronSet(name: string, expression: string, timezone: string | undefined | null, actionName: string, args: Buffer, maxHistory?: number | undefined | null): Promise + cronEvery(name: string, intervalMs: number, actionName: string, args: Buffer, maxHistory?: number | undefined | null): Promise + cronGet(name: string): Promise + cronList(): Promise> + cronDelete(name: string): Promise + cronHistory(name: string, limit?: number | undefined | null): Promise> } export declare class WebSocket { send(data: Buffer, binary: boolean): void diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs index 11f15520e7..0787420054 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs @@ -106,6 +106,7 @@ pub struct JsActorConfig { pub connection_liveness_timeout_ms: Option, pub connection_liveness_interval_ms: Option, pub max_queue_size: Option, + pub max_schedules: Option, pub max_queue_message_size: Option, pub max_incoming_message_size: Option, pub max_outgoing_message_size: Option, @@ -192,6 +193,7 @@ pub(crate) struct ActionPayload { pub(crate) conn: Option, pub(crate) name: String, pub(crate) args: Vec, + pub(crate) scheduled_fire: Option, pub(crate) cancel_token: Option, } @@ -907,6 +909,20 @@ fn build_action_payload(env: &Env, payload: ActionPayload) -> napi::Result object.set("conn", env.get_null()?)?, } object.set("args", Buffer::from(payload.args))?; + if let Some(fire) = payload.scheduled_fire { + let mut fire_object = env.create_object()?; + fire_object.set("kind", fire.kind.as_str())?; + fire_object.set("id", fire.id)?; + match fire.name { + Some(name) => fire_object.set("name", name)?, + None => fire_object.set("name", env.get_undefined()?)?, + } + fire_object.set("scheduledAt", fire.scheduled_at)?; + fire_object.set("firedAt", fire.fired_at)?; + object.set("scheduledFire", fire_object)?; + } else { + object.set("scheduledFire", env.get_undefined()?)?; + } match payload.cancel_token { Some(cancel_token) => object.set("cancelToken", CancellationToken::new(cancel_token))?, None => object.set("cancelToken", env.get_undefined()?)?, @@ -1036,6 +1052,7 @@ impl From for ActorConfigInput { connection_liveness_timeout_ms: value.connection_liveness_timeout_ms, connection_liveness_interval_ms: value.connection_liveness_interval_ms, max_queue_size: value.max_queue_size, + max_schedules: value.max_schedules, max_queue_message_size: value.max_queue_message_size, max_incoming_message_size: value.max_incoming_message_size, max_outgoing_message_size: value.max_outgoing_message_size, 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 34cf0b172b..836113b94a 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/napi_actor_events.rs @@ -88,14 +88,6 @@ static CALLBACK_TIMED_OUT_SCHEMA: RivetErrorSchema = RivetErrorSchema { _macro_marker: MacroMarker { _private: () }, }; -static ACTION_NOT_FOUND_SCHEMA: RivetErrorSchema = RivetErrorSchema { - group: "actor", - code: "action_not_found", - default_message: "Action not found", - meta_type: None, - _macro_marker: MacroMarker { _private: () }, -}; - pub(crate) async fn run_adapter_loop( bindings: Arc, config: Arc, @@ -364,6 +356,7 @@ pub(crate) async fn dispatch_event( name, args, conn, + scheduled_fire, reply, } => { tracing::info!( @@ -401,6 +394,7 @@ pub(crate) async fn dispatch_event( conn, name.clone(), args.clone(), + scheduled_fire.clone(), Some(cancel_token), ), ) @@ -1112,6 +1106,7 @@ async fn call_action( conn: Option, name: String, args: Vec, + scheduled_fire: Option, cancel_token: Option, ) -> Result> { let callback_name = format!("actions.{name}"); @@ -1123,6 +1118,7 @@ async fn call_action( conn, name, args, + scheduled_fire, cancel_token, }, ) @@ -1322,12 +1318,7 @@ async fn call_on_disconnect_final( } fn action_not_found(name: String) -> anyhow::Error { - anyhow::Error::new(RivetTransportError { - kind: RivetErrorKind::Static(&ACTION_NOT_FOUND_SCHEMA), - meta: None, - message: Some(format!("Action `{name}` was not found.")), - actor: None, - }) + rivetkit_core::error::action_not_found(name) } fn actor_shutting_down() -> anyhow::Error { diff --git a/rivetkit-typescript/packages/rivetkit-napi/src/schedule.rs b/rivetkit-typescript/packages/rivetkit-napi/src/schedule.rs index f27b769e1d..4e15a304e6 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/src/schedule.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/src/schedule.rs @@ -3,9 +3,53 @@ use std::time::Duration; use napi::bindgen_prelude::Buffer; use napi_derive::napi; use rivetkit_core::ActorContext as CoreActorContext; +use rivetkit_core::actor::schedule::{ + CronFire as CoreCronFire, CronJobInfo as CoreCronJobInfo, ScheduleKind, + ScheduledEventInfo as CoreScheduledEventInfo, +}; use crate::{NapiInvalidArgument, napi_anyhow_error}; +#[napi(object)] +pub struct JsScheduledEventInfo { + pub id: String, + pub action: String, + pub args: Buffer, + pub run_at: i64, +} + +#[napi(object)] +pub struct JsCronJobInfo { + pub name: String, + pub kind: String, + pub action: String, + pub args: Buffer, + pub next_run_at: i64, + pub last_run_at: Option, + pub expression: Option, + pub timezone: Option, + pub interval_ms: Option, + pub max_history: i64, +} + +#[napi(object)] +pub struct JsScheduleErrorInfo { + pub group: String, + pub code: String, + pub message: String, + pub metadata: Option, +} + +#[napi(object)] +pub struct JsCronFire { + pub action: String, + pub scheduled_at: i64, + pub fired_at: i64, + pub finished_at: Option, + pub result: String, + pub error: Option, +} + #[napi] pub struct Schedule { inner: CoreActorContext, @@ -20,7 +64,12 @@ impl Schedule { #[napi] impl Schedule { #[napi] - pub fn after(&self, duration_ms: i64, action_name: String, args: Buffer) -> napi::Result<()> { + pub async fn after( + &self, + duration_ms: i64, + action_name: String, + args: Buffer, + ) -> napi::Result { let duration_ms = u64::try_from(duration_ms).map_err(|_| { napi_anyhow_error( NapiInvalidArgument { @@ -30,16 +79,180 @@ impl Schedule { .build(), ) })?; - self.inner.after( - Duration::from_millis(duration_ms), - &action_name, - args.as_ref(), - ); - Ok(()) + self.inner + .after( + Duration::from_millis(duration_ms), + &action_name, + args.as_ref(), + ) + .await + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn at( + &self, + timestamp_ms: i64, + action_name: String, + args: Buffer, + ) -> napi::Result { + self.inner + .at(timestamp_ms, &action_name, args.as_ref()) + .await + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn cancel(&self, id: String) -> napi::Result { + self.inner + .cancel_schedule(&id) + .await + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn get(&self, id: String) -> napi::Result> { + self.inner + .get_scheduled_event(&id) + .await + .map(|event| event.map(JsScheduledEventInfo::from)) + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn list(&self) -> napi::Result> { + self.inner + .list_scheduled_events() + .await + .map(|events| events.into_iter().map(Into::into).collect()) + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn cron_set( + &self, + name: String, + expression: String, + timezone: Option, + action_name: String, + args: Buffer, + max_history: Option, + ) -> napi::Result<()> { + self.inner + .cron_set( + &name, + &expression, + timezone.as_deref(), + &action_name, + args.as_ref(), + max_history, + ) + .await + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn cron_every( + &self, + name: String, + interval_ms: i64, + action_name: String, + args: Buffer, + max_history: Option, + ) -> napi::Result<()> { + self.inner + .cron_every(&name, interval_ms, &action_name, args.as_ref(), max_history) + .await + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn cron_get(&self, name: String) -> napi::Result> { + self.inner + .cron_get(&name) + .await + .map(|job| job.map(JsCronJobInfo::from)) + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn cron_list(&self) -> napi::Result> { + self.inner + .cron_list() + .await + .map(|jobs| jobs.into_iter().map(Into::into).collect()) + .map_err(napi_anyhow_error) } #[napi] - pub fn at(&self, timestamp_ms: i64, action_name: String, args: Buffer) { - self.inner.at(timestamp_ms, &action_name, args.as_ref()); + pub async fn cron_delete(&self, name: String) -> napi::Result { + self.inner + .cron_delete(&name) + .await + .map_err(napi_anyhow_error) + } + + #[napi] + pub async fn cron_history( + &self, + name: String, + limit: Option, + ) -> napi::Result> { + self.inner + .cron_history(&name, limit) + .await + .map(|fires| fires.into_iter().map(Into::into).collect()) + .map_err(napi_anyhow_error) + } +} + +impl From for JsScheduledEventInfo { + fn from(value: CoreScheduledEventInfo) -> Self { + Self { + id: value.id, + action: value.action, + args: Buffer::from(value.args), + run_at: value.run_at, + } + } +} + +impl From for JsCronJobInfo { + fn from(value: CoreCronJobInfo) -> Self { + Self { + name: value.name, + kind: match value.kind { + ScheduleKind::At => "at", + ScheduleKind::Cron => "cron", + ScheduleKind::Every => "every", + } + .to_owned(), + action: value.action, + args: Buffer::from(value.args), + next_run_at: value.next_run_at, + last_run_at: value.last_run_at, + expression: value.expression, + timezone: value.timezone, + interval_ms: value.interval_ms, + max_history: value.max_history, + } + } +} + +impl From for JsCronFire { + fn from(value: CoreCronFire) -> Self { + Self { + action: value.action, + scheduled_at: value.scheduled_at, + fired_at: value.fired_at, + finished_at: value.finished_at, + result: value.result, + error: value.error.map(|error| JsScheduleErrorInfo { + group: error.group, + code: error.code, + message: error.message, + metadata: error.metadata, + }), + } } } diff --git a/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs b/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs index 50f78e7e60..a0c33d46c8 100644 --- a/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs +++ b/rivetkit-typescript/packages/rivetkit-napi/tests/napi_actor_events.rs @@ -167,6 +167,7 @@ mod moved_tests { name: "missing".to_owned(), args: vec![1, 2, 3], conn: None, + scheduled_fire: None, reply: tx.into(), }, &bindings, @@ -433,6 +434,7 @@ mod moved_tests { name: "missing-first".to_owned(), args: Vec::new(), conn: None, + scheduled_fire: None, reply: first_tx.into(), }) .expect("first action event should send"); @@ -441,6 +443,7 @@ mod moved_tests { name: "missing-second".to_owned(), args: Vec::new(), conn: None, + scheduled_fire: None, reply: second_tx.into(), }) .expect("second action event should send"); diff --git a/rivetkit-typescript/packages/rivetkit-wasm/index.d.ts b/rivetkit-typescript/packages/rivetkit-wasm/index.d.ts index 64ee16f1ad..f95b9d10e8 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/index.d.ts +++ b/rivetkit-typescript/packages/rivetkit-wasm/index.d.ts @@ -140,8 +140,30 @@ export class QueueMessage { export class Schedule { private constructor(); free(): void; - at(timestamp_ms: number, action_name: string, args: Uint8Array): void; - after(duration_ms: number, action_name: string, args: Uint8Array): void; + after(duration_ms: number, action_name: string, args: Uint8Array): Promise; + at(timestamp_ms: number, action_name: string, args: Uint8Array): Promise; + cancel(id: string): Promise; + get(id: string): Promise; + list(): Promise; + cronSet( + name: string, + expression: string, + timezone: string | null | undefined, + action_name: string, + args: Uint8Array, + max_history?: number | null, + ): Promise; + cronEvery( + name: string, + interval_ms: number, + action_name: string, + args: Uint8Array, + max_history?: number | null, + ): Promise; + cronGet(name: string): Promise; + cronList(): Promise; + cronDelete(name: string): Promise; + cronHistory(name: string, limit?: number | null): Promise; } export class SqliteDb { diff --git a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs index 271e936240..0e97ac27f7 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs @@ -9,7 +9,10 @@ use std::time::Duration; use anyhow::{Result, anyhow}; use js_sys::{Array, Function, Object, Promise, Reflect, Uint8Array}; -use rivet_error::{ActorSpecifier, RivetError as RivetTransportError, RivetErrorKind}; +use rivet_error::{ + ActorSpecifier, MacroMarker, RivetError as RivetTransportError, RivetErrorKind, + RivetErrorSchema, +}; use rivetkit_core::error::public_error_status_code; use rivetkit_core::inspector::InspectorAuth; use rivetkit_core::{ @@ -200,6 +203,7 @@ pub struct WasmActorConfig { pub connection_liveness_timeout_ms: Option, pub connection_liveness_interval_ms: Option, pub max_queue_size: Option, + pub max_schedules: Option, pub max_queue_message_size: Option, pub max_incoming_message_size: Option, pub max_outgoing_message_size: Option, @@ -229,6 +233,7 @@ impl From for ActorConfigInput { connection_liveness_timeout_ms: config.connection_liveness_timeout_ms, connection_liveness_interval_ms: config.connection_liveness_interval_ms, max_queue_size: config.max_queue_size, + max_schedules: config.max_schedules, max_queue_message_size: config.max_queue_message_size, max_incoming_message_size: config.max_incoming_message_size, max_outgoing_message_size: config.max_outgoing_message_size, @@ -673,11 +678,12 @@ async fn dispatch_event(callbacks: &WasmCallbacks, ctx: &WasmActorContext, event name, args, conn, + scheduled_fire, reply, } => { let Some(callback) = action_callback(&callbacks.actions, &name) else { console_error(&format!("wasm action callback `{name}` was not found")); - reply.send(Err(anyhow!("action `{name}` was not found"))); + reply.send(Err(rivetkit_core::error::action_not_found(name))); return; }; @@ -697,6 +703,13 @@ async fn dispatch_event(callbacks: &WasmCallbacks, ctx: &WasmActorContext, event )?; set_anyhow(&payload, "name", JsValue::from_str(&name))?; set_anyhow(&payload, "args", bytes_to_js(&args))?; + if let Some(scheduled_fire) = &scheduled_fire { + let scheduled_fire = + serde_wasm_bindgen::to_value(scheduled_fire).map_err(|error| { + anyhow!("serialize scheduled fire metadata: {error:?}") + })?; + set_anyhow(&payload, "scheduledFire", scheduled_fire)?; + } let mut output = call_callback_bytes(&callback, &payload.into()).await?; if let Some(callback) = &on_before_action_response { @@ -1657,18 +1670,140 @@ pub struct WasmSchedule { #[wasm_bindgen(js_class = Schedule)] impl WasmSchedule { #[wasm_bindgen] - pub fn after(&self, duration_ms: f64, action_name: String, args: Vec) { + pub async fn after( + &self, + duration_ms: f64, + action_name: String, + args: Vec, + ) -> Result { let duration = if duration_ms.is_finite() && duration_ms > 0.0 { Duration::from_millis(duration_ms as u64) } else { Duration::from_millis(0) }; - self.inner.after(duration, &action_name, &args); + self.inner + .after(duration, &action_name, &args) + .await + .map_err(anyhow_to_js_error) + } + + #[wasm_bindgen] + pub async fn at( + &self, + timestamp_ms: f64, + action_name: String, + args: Vec, + ) -> Result { + self.inner + .at(timestamp_ms as i64, &action_name, &args) + .await + .map_err(anyhow_to_js_error) + } + + #[wasm_bindgen] + pub async fn cancel(&self, id: String) -> Result { + self.inner + .cancel_schedule(&id) + .await + .map_err(anyhow_to_js_error) + } + + #[wasm_bindgen] + pub async fn get(&self, id: String) -> Result { + let event = self + .inner + .get_scheduled_event(&id) + .await + .map_err(anyhow_to_js_error)?; + serde_wasm_bindgen::to_value(&event).map_err(Into::into) } #[wasm_bindgen] - pub fn at(&self, timestamp_ms: f64, action_name: String, args: Vec) { - self.inner.at(timestamp_ms as i64, &action_name, &args); + pub async fn list(&self) -> Result { + let events = self + .inner + .list_scheduled_events() + .await + .map_err(anyhow_to_js_error)?; + serde_wasm_bindgen::to_value(&events).map_err(Into::into) + } + + #[wasm_bindgen(js_name = cronSet)] + pub async fn cron_set( + &self, + name: String, + expression: String, + timezone: Option, + action_name: String, + args: Vec, + max_history: Option, + ) -> Result<(), JsValue> { + self.inner + .cron_set( + &name, + &expression, + timezone.as_deref(), + &action_name, + &args, + max_history.map(|value| value as i64), + ) + .await + .map_err(anyhow_to_js_error) + } + + #[wasm_bindgen(js_name = cronEvery)] + pub async fn cron_every( + &self, + name: String, + interval_ms: f64, + action_name: String, + args: Vec, + max_history: Option, + ) -> Result<(), JsValue> { + self.inner + .cron_every( + &name, + interval_ms as i64, + &action_name, + &args, + max_history.map(|value| value as i64), + ) + .await + .map_err(anyhow_to_js_error) + } + + #[wasm_bindgen(js_name = cronGet)] + pub async fn cron_get(&self, name: String) -> Result { + let job = self + .inner + .cron_get(&name) + .await + .map_err(anyhow_to_js_error)?; + serde_wasm_bindgen::to_value(&job).map_err(Into::into) + } + + #[wasm_bindgen(js_name = cronList)] + pub async fn cron_list(&self) -> Result { + let jobs = self.inner.cron_list().await.map_err(anyhow_to_js_error)?; + serde_wasm_bindgen::to_value(&jobs).map_err(Into::into) + } + + #[wasm_bindgen(js_name = cronDelete)] + pub async fn cron_delete(&self, name: String) -> Result { + self.inner + .cron_delete(&name) + .await + .map_err(anyhow_to_js_error) + } + + #[wasm_bindgen(js_name = cronHistory)] + pub async fn cron_history(&self, name: String, limit: Option) -> Result { + let history = self + .inner + .cron_history(&name, limit.map(|value| value as i64)) + .await + .map_err(anyhow_to_js_error)?; + serde_wasm_bindgen::to_value(&history).map_err(Into::into) } } diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts index e3aa29da93..850e75606a 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts @@ -107,11 +107,89 @@ export interface ActorKv { } export interface ActorSchedule { - after(duration: number, action: string, ...args: unknown[]): Promise; - at(timestamp: number, action: string, ...args: unknown[]): Promise; + after( + duration: number, + action: string, + ...args: unknown[] + ): Promise; + at(timestamp: number, action: string, ...args: unknown[]): Promise; + cancel(id: string): Promise; + get(id: string): Promise; + list(): Promise; [key: string]: any; } +export interface ActorCronSetOptions { + name: string; + expression: string; + action: string; + args?: unknown[]; + timezone?: string; + /** Defaults to 100. Set to 0 to disable and clear history. Maximum 1,000. */ + maxHistory?: number; +} + +export interface ActorCronEveryOptions { + name: string; + /** Fixed interval in milliseconds. Minimum 5,000. */ + intervalMs: number; + action: string; + args?: unknown[]; + /** Defaults to 100. Set to 0 to disable and clear history. Maximum 1,000. */ + maxHistory?: number; +} + +export interface ActorCron { + set(options: ActorCronSetOptions): Promise; + every(options: ActorCronEveryOptions): Promise; + get(name: string): Promise; + list(): Promise; + delete(name: string): Promise; + history(name: string, options?: { limit?: number }): Promise; + [key: string]: any; +} + +export interface ScheduledEventInfo { + id: string; + action: string; + args: unknown[]; + runAt: number; +} + +export type CronJobInfo = { + name: string; + action: string; + args: unknown[]; + nextRunAt: number; + lastRunAt?: number; + maxHistory: number; +} & ( + | { kind: "cron"; expression: string; timezone: string } + | { kind: "every"; intervalMs: number } +); + +export interface ScheduledFireInfo { + kind: "at" | "cron" | "every"; + id: string; + name?: string; + scheduledAt: number; + firedAt: number; +} + +export interface CronFire { + action: string; + scheduledAt: number; + firedAt: number; + finishedAt?: number; + result: "running" | "ok" | "error" | "skipped"; + error?: { + group: string; + code: string; + message: string; + metadata?: unknown; + }; +} + export type QueueMessageOf = { id: number | bigint; name: Name; @@ -316,6 +394,7 @@ export interface ActorContext< readonly kv: ActorKv; readonly db: InferDatabaseClient; readonly schedule: ActorSchedule; + readonly cron: ActorCron; readonly queue: ActorQueue; readonly actorId: string; readonly name: string; @@ -755,6 +834,7 @@ export const BUILTIN_INSPECTOR_TAB_IDS = [ "database", "state", "queue", + "schedules", "connections", "console", ] as const; @@ -1003,6 +1083,8 @@ const InstanceActorOptionsBaseSchema = z noSleep: z.boolean().default(false), sleepTimeout: z.number().positive().default(30_000), maxQueueSize: z.number().positive().default(1000), + /** Maximum pending one-shot and recurring schedules. */ + maxSchedules: z.number().int().nonnegative().default(1000), maxQueueMessageSize: z .number() .positive() @@ -1959,6 +2041,14 @@ export const DocActorOptionsSchema = z .describe( "Maximum number of queue messages before rejecting new messages. Default: 1000", ), + maxSchedules: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Maximum pending one-shot and recurring schedules before rejecting new schedules. Default: 1000", + ), maxQueueMessageSize: z .number() .optional() diff --git a/rivetkit-typescript/packages/rivetkit/src/common/bare/generated/inspector/v6.ts b/rivetkit-typescript/packages/rivetkit/src/common/bare/generated/inspector/v6.ts new file mode 100644 index 0000000000..fc2e1de986 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/src/common/bare/generated/inspector/v6.ts @@ -0,0 +1,1367 @@ +// @generated - post-processed by build.rs +import * as bare from "@rivetkit/bare-ts" + +const DEFAULT_CONFIG = /* @__PURE__ */ bare.Config({}) + +export type uint = bigint + +export type State = ArrayBuffer + +export function readState(bc: bare.ByteCursor): State { + return bare.readData(bc) +} + +export function writeState(bc: bare.ByteCursor, x: State): void { + bare.writeData(bc, x) +} + +export type WorkflowHistory = ArrayBuffer + +export function readWorkflowHistory(bc: bare.ByteCursor): WorkflowHistory { + return bare.readData(bc) +} + +export function writeWorkflowHistory(bc: bare.ByteCursor, x: WorkflowHistory): void { + bare.writeData(bc, x) +} + +export type PatchStateRequest = { + readonly state: ArrayBuffer +} + +export function readPatchStateRequest(bc: bare.ByteCursor): PatchStateRequest { + return { + state: bare.readData(bc), + } +} + +export function writePatchStateRequest(bc: bare.ByteCursor, x: PatchStateRequest): void { + bare.writeData(bc, x.state) +} + +export type ActionRequest = { + readonly id: uint + readonly name: string + readonly args: ArrayBuffer +} + +export function readActionRequest(bc: bare.ByteCursor): ActionRequest { + return { + id: bare.readUint(bc), + name: bare.readString(bc), + args: bare.readData(bc), + } +} + +export function writeActionRequest(bc: bare.ByteCursor, x: ActionRequest): void { + bare.writeUint(bc, x.id) + bare.writeString(bc, x.name) + bare.writeData(bc, x.args) +} + +export type StateRequest = { + readonly id: uint +} + +export function readStateRequest(bc: bare.ByteCursor): StateRequest { + return { + id: bare.readUint(bc), + } +} + +export function writeStateRequest(bc: bare.ByteCursor, x: StateRequest): void { + bare.writeUint(bc, x.id) +} + +export type ConnectionsRequest = { + readonly id: uint +} + +export function readConnectionsRequest(bc: bare.ByteCursor): ConnectionsRequest { + return { + id: bare.readUint(bc), + } +} + +export function writeConnectionsRequest(bc: bare.ByteCursor, x: ConnectionsRequest): void { + bare.writeUint(bc, x.id) +} + +export type RpcsListRequest = { + readonly id: uint +} + +export function readRpcsListRequest(bc: bare.ByteCursor): RpcsListRequest { + return { + id: bare.readUint(bc), + } +} + +export function writeRpcsListRequest(bc: bare.ByteCursor, x: RpcsListRequest): void { + bare.writeUint(bc, x.id) +} + +export type TraceQueryRequest = { + readonly id: uint + readonly startMs: uint + readonly endMs: uint + readonly limit: uint +} + +export function readTraceQueryRequest(bc: bare.ByteCursor): TraceQueryRequest { + return { + id: bare.readUint(bc), + startMs: bare.readUint(bc), + endMs: bare.readUint(bc), + limit: bare.readUint(bc), + } +} + +export function writeTraceQueryRequest(bc: bare.ByteCursor, x: TraceQueryRequest): void { + bare.writeUint(bc, x.id) + bare.writeUint(bc, x.startMs) + bare.writeUint(bc, x.endMs) + bare.writeUint(bc, x.limit) +} + +export type QueueRequest = { + readonly id: uint + readonly limit: uint +} + +export function readQueueRequest(bc: bare.ByteCursor): QueueRequest { + return { + id: bare.readUint(bc), + limit: bare.readUint(bc), + } +} + +export function writeQueueRequest(bc: bare.ByteCursor, x: QueueRequest): void { + bare.writeUint(bc, x.id) + bare.writeUint(bc, x.limit) +} + +export type WorkflowHistoryRequest = { + readonly id: uint +} + +export function readWorkflowHistoryRequest(bc: bare.ByteCursor): WorkflowHistoryRequest { + return { + id: bare.readUint(bc), + } +} + +export function writeWorkflowHistoryRequest(bc: bare.ByteCursor, x: WorkflowHistoryRequest): void { + bare.writeUint(bc, x.id) +} + +function read0(bc: bare.ByteCursor): string | null { + return bare.readBool(bc) ? bare.readString(bc) : null +} + +function write0(bc: bare.ByteCursor, x: string | null): void { + bare.writeBool(bc, x != null) + if (x != null) { + bare.writeString(bc, x) + } +} + +export type WorkflowReplayRequest = { + readonly id: uint + readonly entryId: string | null +} + +export function readWorkflowReplayRequest(bc: bare.ByteCursor): WorkflowReplayRequest { + return { + id: bare.readUint(bc), + entryId: read0(bc), + } +} + +export function writeWorkflowReplayRequest(bc: bare.ByteCursor, x: WorkflowReplayRequest): void { + bare.writeUint(bc, x.id) + write0(bc, x.entryId) +} + +export type DatabaseSchemaRequest = { + readonly id: uint +} + +export function readDatabaseSchemaRequest(bc: bare.ByteCursor): DatabaseSchemaRequest { + return { + id: bare.readUint(bc), + } +} + +export function writeDatabaseSchemaRequest(bc: bare.ByteCursor, x: DatabaseSchemaRequest): void { + bare.writeUint(bc, x.id) +} + +export type DatabaseTableRowsRequest = { + readonly id: uint + readonly table: string + readonly limit: uint + readonly offset: uint +} + +export function readDatabaseTableRowsRequest(bc: bare.ByteCursor): DatabaseTableRowsRequest { + return { + id: bare.readUint(bc), + table: bare.readString(bc), + limit: bare.readUint(bc), + offset: bare.readUint(bc), + } +} + +export function writeDatabaseTableRowsRequest(bc: bare.ByteCursor, x: DatabaseTableRowsRequest): void { + bare.writeUint(bc, x.id) + bare.writeString(bc, x.table) + bare.writeUint(bc, x.limit) + bare.writeUint(bc, x.offset) +} + +export type SchedulesRequest = { + readonly id: uint +} + +export function readSchedulesRequest(bc: bare.ByteCursor): SchedulesRequest { + return { + id: bare.readUint(bc), + } +} + +export function writeSchedulesRequest(bc: bare.ByteCursor, x: SchedulesRequest): void { + bare.writeUint(bc, x.id) +} + +export type ScheduleHistoryRequest = { + readonly id: uint + readonly scheduleId: string + readonly limit: uint +} + +export function readScheduleHistoryRequest(bc: bare.ByteCursor): ScheduleHistoryRequest { + return { + id: bare.readUint(bc), + scheduleId: bare.readString(bc), + limit: bare.readUint(bc), + } +} + +export function writeScheduleHistoryRequest(bc: bare.ByteCursor, x: ScheduleHistoryRequest): void { + bare.writeUint(bc, x.id) + bare.writeString(bc, x.scheduleId) + bare.writeUint(bc, x.limit) +} + +export type ScheduleDeleteRequest = { + readonly id: uint + readonly scheduleId: string + readonly kind: string +} + +export function readScheduleDeleteRequest(bc: bare.ByteCursor): ScheduleDeleteRequest { + return { + id: bare.readUint(bc), + scheduleId: bare.readString(bc), + kind: bare.readString(bc), + } +} + +export function writeScheduleDeleteRequest(bc: bare.ByteCursor, x: ScheduleDeleteRequest): void { + bare.writeUint(bc, x.id) + bare.writeString(bc, x.scheduleId) + bare.writeString(bc, x.kind) +} + +export type ToServerBody = + | { readonly tag: "PatchStateRequest"; readonly val: PatchStateRequest } + | { readonly tag: "StateRequest"; readonly val: StateRequest } + | { readonly tag: "ConnectionsRequest"; readonly val: ConnectionsRequest } + | { readonly tag: "ActionRequest"; readonly val: ActionRequest } + | { readonly tag: "RpcsListRequest"; readonly val: RpcsListRequest } + | { readonly tag: "TraceQueryRequest"; readonly val: TraceQueryRequest } + | { readonly tag: "QueueRequest"; readonly val: QueueRequest } + | { readonly tag: "WorkflowHistoryRequest"; readonly val: WorkflowHistoryRequest } + | { readonly tag: "WorkflowReplayRequest"; readonly val: WorkflowReplayRequest } + | { readonly tag: "DatabaseSchemaRequest"; readonly val: DatabaseSchemaRequest } + | { readonly tag: "DatabaseTableRowsRequest"; readonly val: DatabaseTableRowsRequest } + | { readonly tag: "SchedulesRequest"; readonly val: SchedulesRequest } + | { readonly tag: "ScheduleHistoryRequest"; readonly val: ScheduleHistoryRequest } + | { readonly tag: "ScheduleDeleteRequest"; readonly val: ScheduleDeleteRequest } + +export function readToServerBody(bc: bare.ByteCursor): ToServerBody { + const offset = bc.offset + const tag = bare.readU8(bc) + switch (tag) { + case 0: + return { tag: "PatchStateRequest", val: readPatchStateRequest(bc) } + case 1: + return { tag: "StateRequest", val: readStateRequest(bc) } + case 2: + return { tag: "ConnectionsRequest", val: readConnectionsRequest(bc) } + case 3: + return { tag: "ActionRequest", val: readActionRequest(bc) } + case 4: + return { tag: "RpcsListRequest", val: readRpcsListRequest(bc) } + case 5: + return { tag: "TraceQueryRequest", val: readTraceQueryRequest(bc) } + case 6: + return { tag: "QueueRequest", val: readQueueRequest(bc) } + case 7: + return { tag: "WorkflowHistoryRequest", val: readWorkflowHistoryRequest(bc) } + case 8: + return { tag: "WorkflowReplayRequest", val: readWorkflowReplayRequest(bc) } + case 9: + return { tag: "DatabaseSchemaRequest", val: readDatabaseSchemaRequest(bc) } + case 10: + return { tag: "DatabaseTableRowsRequest", val: readDatabaseTableRowsRequest(bc) } + case 11: + return { tag: "SchedulesRequest", val: readSchedulesRequest(bc) } + case 12: + return { tag: "ScheduleHistoryRequest", val: readScheduleHistoryRequest(bc) } + case 13: + return { tag: "ScheduleDeleteRequest", val: readScheduleDeleteRequest(bc) } + default: { + bc.offset = offset + throw new bare.BareError(offset, "invalid tag") + } + } +} + +export function writeToServerBody(bc: bare.ByteCursor, x: ToServerBody): void { + switch (x.tag) { + case "PatchStateRequest": { + bare.writeU8(bc, 0) + writePatchStateRequest(bc, x.val) + break + } + case "StateRequest": { + bare.writeU8(bc, 1) + writeStateRequest(bc, x.val) + break + } + case "ConnectionsRequest": { + bare.writeU8(bc, 2) + writeConnectionsRequest(bc, x.val) + break + } + case "ActionRequest": { + bare.writeU8(bc, 3) + writeActionRequest(bc, x.val) + break + } + case "RpcsListRequest": { + bare.writeU8(bc, 4) + writeRpcsListRequest(bc, x.val) + break + } + case "TraceQueryRequest": { + bare.writeU8(bc, 5) + writeTraceQueryRequest(bc, x.val) + break + } + case "QueueRequest": { + bare.writeU8(bc, 6) + writeQueueRequest(bc, x.val) + break + } + case "WorkflowHistoryRequest": { + bare.writeU8(bc, 7) + writeWorkflowHistoryRequest(bc, x.val) + break + } + case "WorkflowReplayRequest": { + bare.writeU8(bc, 8) + writeWorkflowReplayRequest(bc, x.val) + break + } + case "DatabaseSchemaRequest": { + bare.writeU8(bc, 9) + writeDatabaseSchemaRequest(bc, x.val) + break + } + case "DatabaseTableRowsRequest": { + bare.writeU8(bc, 10) + writeDatabaseTableRowsRequest(bc, x.val) + break + } + case "SchedulesRequest": { + bare.writeU8(bc, 11) + writeSchedulesRequest(bc, x.val) + break + } + case "ScheduleHistoryRequest": { + bare.writeU8(bc, 12) + writeScheduleHistoryRequest(bc, x.val) + break + } + case "ScheduleDeleteRequest": { + bare.writeU8(bc, 13) + writeScheduleDeleteRequest(bc, x.val) + break + } + } +} + +export type ToServer = { + readonly body: ToServerBody +} + +export function readToServer(bc: bare.ByteCursor): ToServer { + return { + body: readToServerBody(bc), + } +} + +export function writeToServer(bc: bare.ByteCursor, x: ToServer): void { + writeToServerBody(bc, x.body) +} + +export function encodeToServer(x: ToServer, config?: Partial): Uint8Array { + const fullConfig = config != null ? bare.Config(config) : DEFAULT_CONFIG + const bc = new bare.ByteCursor( + new Uint8Array(fullConfig.initialBufferLength), + fullConfig, + ) + writeToServer(bc, x) + return new Uint8Array(bc.view.buffer, bc.view.byteOffset, bc.offset) +} + +export function decodeToServer(bytes: Uint8Array): ToServer { + const bc = new bare.ByteCursor(bytes, DEFAULT_CONFIG) + const result = readToServer(bc) + if (bc.offset < bc.view.byteLength) { + throw new bare.BareError(bc.offset, "remaining bytes") + } + return result +} + +export type Connection = { + readonly id: string + readonly details: ArrayBuffer +} + +export function readConnection(bc: bare.ByteCursor): Connection { + return { + id: bare.readString(bc), + details: bare.readData(bc), + } +} + +export function writeConnection(bc: bare.ByteCursor, x: Connection): void { + bare.writeString(bc, x.id) + bare.writeData(bc, x.details) +} + +/** + * Inspector tab descriptor. `label`/`icon` are set for author-declared custom + * tabs; a hidden built-in only carries `id` with `hidden` set. + */ +export type TabConfigEntry = { + readonly id: string + readonly label: string | null + readonly icon: string | null + readonly hidden: boolean +} + +export function readTabConfigEntry(bc: bare.ByteCursor): TabConfigEntry { + return { + id: bare.readString(bc), + label: read0(bc), + icon: read0(bc), + hidden: bare.readBool(bc), + } +} + +export function writeTabConfigEntry(bc: bare.ByteCursor, x: TabConfigEntry): void { + bare.writeString(bc, x.id) + write0(bc, x.label) + write0(bc, x.icon) + bare.writeBool(bc, x.hidden) +} + +function read1(bc: bare.ByteCursor): uint | null { + return bare.readBool(bc) ? bare.readUint(bc) : null +} + +function write1(bc: bare.ByteCursor, x: uint | null): void { + bare.writeBool(bc, x != null) + if (x != null) { + bare.writeUint(bc, x) + } +} + +export type Schedule = { + readonly id: string + readonly name: string | null + readonly kind: string + readonly action: string + readonly args: ArrayBuffer + readonly nextRunAt: uint + readonly lastRunAt: uint | null + readonly expression: string | null + readonly timezone: string | null + readonly intervalMs: uint | null + readonly maxHistory: uint | null +} + +export function readSchedule(bc: bare.ByteCursor): Schedule { + return { + id: bare.readString(bc), + name: read0(bc), + kind: bare.readString(bc), + action: bare.readString(bc), + args: bare.readData(bc), + nextRunAt: bare.readUint(bc), + lastRunAt: read1(bc), + expression: read0(bc), + timezone: read0(bc), + intervalMs: read1(bc), + maxHistory: read1(bc), + } +} + +export function writeSchedule(bc: bare.ByteCursor, x: Schedule): void { + bare.writeString(bc, x.id) + write0(bc, x.name) + bare.writeString(bc, x.kind) + bare.writeString(bc, x.action) + bare.writeData(bc, x.args) + bare.writeUint(bc, x.nextRunAt) + write1(bc, x.lastRunAt) + write0(bc, x.expression) + write0(bc, x.timezone) + write1(bc, x.intervalMs) + write1(bc, x.maxHistory) +} + +function read2(bc: bare.ByteCursor): ArrayBuffer | null { + return bare.readBool(bc) ? bare.readData(bc) : null +} + +function write2(bc: bare.ByteCursor, x: ArrayBuffer | null): void { + bare.writeBool(bc, x != null) + if (x != null) { + bare.writeData(bc, x) + } +} + +export type ScheduleError = { + readonly group: string + readonly code: string + readonly message: string + readonly metadata: ArrayBuffer | null +} + +export function readScheduleError(bc: bare.ByteCursor): ScheduleError { + return { + group: bare.readString(bc), + code: bare.readString(bc), + message: bare.readString(bc), + metadata: read2(bc), + } +} + +export function writeScheduleError(bc: bare.ByteCursor, x: ScheduleError): void { + bare.writeString(bc, x.group) + bare.writeString(bc, x.code) + bare.writeString(bc, x.message) + write2(bc, x.metadata) +} + +function read3(bc: bare.ByteCursor): ScheduleError | null { + return bare.readBool(bc) ? readScheduleError(bc) : null +} + +function write3(bc: bare.ByteCursor, x: ScheduleError | null): void { + bare.writeBool(bc, x != null) + if (x != null) { + writeScheduleError(bc, x) + } +} + +export type ScheduleFire = { + readonly action: string + readonly scheduledAt: uint + readonly firedAt: uint + readonly finishedAt: uint | null + readonly result: string + readonly error: ScheduleError | null +} + +export function readScheduleFire(bc: bare.ByteCursor): ScheduleFire { + return { + action: bare.readString(bc), + scheduledAt: bare.readUint(bc), + firedAt: bare.readUint(bc), + finishedAt: read1(bc), + result: bare.readString(bc), + error: read3(bc), + } +} + +export function writeScheduleFire(bc: bare.ByteCursor, x: ScheduleFire): void { + bare.writeString(bc, x.action) + bare.writeUint(bc, x.scheduledAt) + bare.writeUint(bc, x.firedAt) + write1(bc, x.finishedAt) + bare.writeString(bc, x.result) + write3(bc, x.error) +} + +function read4(bc: bare.ByteCursor): readonly Connection[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readConnection(bc)] + for (let i = 1; i < len; i++) { + result[i] = readConnection(bc) + } + return result +} + +function write4(bc: bare.ByteCursor, x: readonly Connection[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeConnection(bc, x[i]) + } +} + +function read5(bc: bare.ByteCursor): State | null { + return bare.readBool(bc) ? readState(bc) : null +} + +function write5(bc: bare.ByteCursor, x: State | null): void { + bare.writeBool(bc, x != null) + if (x != null) { + writeState(bc, x) + } +} + +function read6(bc: bare.ByteCursor): readonly string[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [bare.readString(bc)] + for (let i = 1; i < len; i++) { + result[i] = bare.readString(bc) + } + return result +} + +function write6(bc: bare.ByteCursor, x: readonly string[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + bare.writeString(bc, x[i]) + } +} + +function read7(bc: bare.ByteCursor): WorkflowHistory | null { + return bare.readBool(bc) ? readWorkflowHistory(bc) : null +} + +function write7(bc: bare.ByteCursor, x: WorkflowHistory | null): void { + bare.writeBool(bc, x != null) + if (x != null) { + writeWorkflowHistory(bc, x) + } +} + +function read8(bc: bare.ByteCursor): readonly TabConfigEntry[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readTabConfigEntry(bc)] + for (let i = 1; i < len; i++) { + result[i] = readTabConfigEntry(bc) + } + return result +} + +function write8(bc: bare.ByteCursor, x: readonly TabConfigEntry[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeTabConfigEntry(bc, x[i]) + } +} + +function read9(bc: bare.ByteCursor): readonly Schedule[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readSchedule(bc)] + for (let i = 1; i < len; i++) { + result[i] = readSchedule(bc) + } + return result +} + +function write9(bc: bare.ByteCursor, x: readonly Schedule[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeSchedule(bc, x[i]) + } +} + +export type Init = { + readonly connections: readonly Connection[] + readonly state: State | null + readonly isStateEnabled: boolean + readonly rpcs: readonly string[] + readonly isDatabaseEnabled: boolean + readonly queueSize: uint + readonly workflowHistory: WorkflowHistory | null + readonly isWorkflowEnabled: boolean + readonly tabConfig: readonly TabConfigEntry[] + readonly schedules: readonly Schedule[] +} + +export function readInit(bc: bare.ByteCursor): Init { + return { + connections: read4(bc), + state: read5(bc), + isStateEnabled: bare.readBool(bc), + rpcs: read6(bc), + isDatabaseEnabled: bare.readBool(bc), + queueSize: bare.readUint(bc), + workflowHistory: read7(bc), + isWorkflowEnabled: bare.readBool(bc), + tabConfig: read8(bc), + schedules: read9(bc), + } +} + +export function writeInit(bc: bare.ByteCursor, x: Init): void { + write4(bc, x.connections) + write5(bc, x.state) + bare.writeBool(bc, x.isStateEnabled) + write6(bc, x.rpcs) + bare.writeBool(bc, x.isDatabaseEnabled) + bare.writeUint(bc, x.queueSize) + write7(bc, x.workflowHistory) + bare.writeBool(bc, x.isWorkflowEnabled) + write8(bc, x.tabConfig) + write9(bc, x.schedules) +} + +export type ConnectionsResponse = { + readonly rid: uint + readonly connections: readonly Connection[] +} + +export function readConnectionsResponse(bc: bare.ByteCursor): ConnectionsResponse { + return { + rid: bare.readUint(bc), + connections: read4(bc), + } +} + +export function writeConnectionsResponse(bc: bare.ByteCursor, x: ConnectionsResponse): void { + bare.writeUint(bc, x.rid) + write4(bc, x.connections) +} + +export type StateResponse = { + readonly rid: uint + readonly state: State | null + readonly isStateEnabled: boolean +} + +export function readStateResponse(bc: bare.ByteCursor): StateResponse { + return { + rid: bare.readUint(bc), + state: read5(bc), + isStateEnabled: bare.readBool(bc), + } +} + +export function writeStateResponse(bc: bare.ByteCursor, x: StateResponse): void { + bare.writeUint(bc, x.rid) + write5(bc, x.state) + bare.writeBool(bc, x.isStateEnabled) +} + +export type ActionResponse = { + readonly rid: uint + readonly output: ArrayBuffer +} + +export function readActionResponse(bc: bare.ByteCursor): ActionResponse { + return { + rid: bare.readUint(bc), + output: bare.readData(bc), + } +} + +export function writeActionResponse(bc: bare.ByteCursor, x: ActionResponse): void { + bare.writeUint(bc, x.rid) + bare.writeData(bc, x.output) +} + +export type TraceQueryResponse = { + readonly rid: uint + readonly payload: ArrayBuffer +} + +export function readTraceQueryResponse(bc: bare.ByteCursor): TraceQueryResponse { + return { + rid: bare.readUint(bc), + payload: bare.readData(bc), + } +} + +export function writeTraceQueryResponse(bc: bare.ByteCursor, x: TraceQueryResponse): void { + bare.writeUint(bc, x.rid) + bare.writeData(bc, x.payload) +} + +export type QueueMessageSummary = { + readonly id: uint + readonly name: string + readonly createdAtMs: uint +} + +export function readQueueMessageSummary(bc: bare.ByteCursor): QueueMessageSummary { + return { + id: bare.readUint(bc), + name: bare.readString(bc), + createdAtMs: bare.readUint(bc), + } +} + +export function writeQueueMessageSummary(bc: bare.ByteCursor, x: QueueMessageSummary): void { + bare.writeUint(bc, x.id) + bare.writeString(bc, x.name) + bare.writeUint(bc, x.createdAtMs) +} + +function read10(bc: bare.ByteCursor): readonly QueueMessageSummary[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readQueueMessageSummary(bc)] + for (let i = 1; i < len; i++) { + result[i] = readQueueMessageSummary(bc) + } + return result +} + +function write10(bc: bare.ByteCursor, x: readonly QueueMessageSummary[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeQueueMessageSummary(bc, x[i]) + } +} + +export type QueueStatus = { + readonly size: uint + readonly maxSize: uint + readonly messages: readonly QueueMessageSummary[] + readonly truncated: boolean +} + +export function readQueueStatus(bc: bare.ByteCursor): QueueStatus { + return { + size: bare.readUint(bc), + maxSize: bare.readUint(bc), + messages: read10(bc), + truncated: bare.readBool(bc), + } +} + +export function writeQueueStatus(bc: bare.ByteCursor, x: QueueStatus): void { + bare.writeUint(bc, x.size) + bare.writeUint(bc, x.maxSize) + write10(bc, x.messages) + bare.writeBool(bc, x.truncated) +} + +export type QueueResponse = { + readonly rid: uint + readonly status: QueueStatus +} + +export function readQueueResponse(bc: bare.ByteCursor): QueueResponse { + return { + rid: bare.readUint(bc), + status: readQueueStatus(bc), + } +} + +export function writeQueueResponse(bc: bare.ByteCursor, x: QueueResponse): void { + bare.writeUint(bc, x.rid) + writeQueueStatus(bc, x.status) +} + +export type WorkflowHistoryResponse = { + readonly rid: uint + readonly history: WorkflowHistory | null + readonly isWorkflowEnabled: boolean +} + +export function readWorkflowHistoryResponse(bc: bare.ByteCursor): WorkflowHistoryResponse { + return { + rid: bare.readUint(bc), + history: read7(bc), + isWorkflowEnabled: bare.readBool(bc), + } +} + +export function writeWorkflowHistoryResponse(bc: bare.ByteCursor, x: WorkflowHistoryResponse): void { + bare.writeUint(bc, x.rid) + write7(bc, x.history) + bare.writeBool(bc, x.isWorkflowEnabled) +} + +export type WorkflowReplayResponse = { + readonly rid: uint + readonly history: WorkflowHistory | null + readonly isWorkflowEnabled: boolean +} + +export function readWorkflowReplayResponse(bc: bare.ByteCursor): WorkflowReplayResponse { + return { + rid: bare.readUint(bc), + history: read7(bc), + isWorkflowEnabled: bare.readBool(bc), + } +} + +export function writeWorkflowReplayResponse(bc: bare.ByteCursor, x: WorkflowReplayResponse): void { + bare.writeUint(bc, x.rid) + write7(bc, x.history) + bare.writeBool(bc, x.isWorkflowEnabled) +} + +export type DatabaseSchemaResponse = { + readonly rid: uint + readonly schema: ArrayBuffer +} + +export function readDatabaseSchemaResponse(bc: bare.ByteCursor): DatabaseSchemaResponse { + return { + rid: bare.readUint(bc), + schema: bare.readData(bc), + } +} + +export function writeDatabaseSchemaResponse(bc: bare.ByteCursor, x: DatabaseSchemaResponse): void { + bare.writeUint(bc, x.rid) + bare.writeData(bc, x.schema) +} + +export type DatabaseTableRowsResponse = { + readonly rid: uint + readonly result: ArrayBuffer +} + +export function readDatabaseTableRowsResponse(bc: bare.ByteCursor): DatabaseTableRowsResponse { + return { + rid: bare.readUint(bc), + result: bare.readData(bc), + } +} + +export function writeDatabaseTableRowsResponse(bc: bare.ByteCursor, x: DatabaseTableRowsResponse): void { + bare.writeUint(bc, x.rid) + bare.writeData(bc, x.result) +} + +export type SchedulesResponse = { + readonly rid: uint + readonly schedules: readonly Schedule[] +} + +export function readSchedulesResponse(bc: bare.ByteCursor): SchedulesResponse { + return { + rid: bare.readUint(bc), + schedules: read9(bc), + } +} + +export function writeSchedulesResponse(bc: bare.ByteCursor, x: SchedulesResponse): void { + bare.writeUint(bc, x.rid) + write9(bc, x.schedules) +} + +function read11(bc: bare.ByteCursor): readonly ScheduleFire[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readScheduleFire(bc)] + for (let i = 1; i < len; i++) { + result[i] = readScheduleFire(bc) + } + return result +} + +function write11(bc: bare.ByteCursor, x: readonly ScheduleFire[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeScheduleFire(bc, x[i]) + } +} + +export type ScheduleHistoryResponse = { + readonly rid: uint + readonly scheduleId: string + readonly history: readonly ScheduleFire[] +} + +export function readScheduleHistoryResponse(bc: bare.ByteCursor): ScheduleHistoryResponse { + return { + rid: bare.readUint(bc), + scheduleId: bare.readString(bc), + history: read11(bc), + } +} + +export function writeScheduleHistoryResponse(bc: bare.ByteCursor, x: ScheduleHistoryResponse): void { + bare.writeUint(bc, x.rid) + bare.writeString(bc, x.scheduleId) + write11(bc, x.history) +} + +export type ScheduleDeleteResponse = { + readonly rid: uint + readonly scheduleId: string + readonly deleted: boolean +} + +export function readScheduleDeleteResponse(bc: bare.ByteCursor): ScheduleDeleteResponse { + return { + rid: bare.readUint(bc), + scheduleId: bare.readString(bc), + deleted: bare.readBool(bc), + } +} + +export function writeScheduleDeleteResponse(bc: bare.ByteCursor, x: ScheduleDeleteResponse): void { + bare.writeUint(bc, x.rid) + bare.writeString(bc, x.scheduleId) + bare.writeBool(bc, x.deleted) +} + +export type StateUpdated = { + readonly state: State +} + +export function readStateUpdated(bc: bare.ByteCursor): StateUpdated { + return { + state: readState(bc), + } +} + +export function writeStateUpdated(bc: bare.ByteCursor, x: StateUpdated): void { + writeState(bc, x.state) +} + +export type QueueUpdated = { + readonly queueSize: uint +} + +export function readQueueUpdated(bc: bare.ByteCursor): QueueUpdated { + return { + queueSize: bare.readUint(bc), + } +} + +export function writeQueueUpdated(bc: bare.ByteCursor, x: QueueUpdated): void { + bare.writeUint(bc, x.queueSize) +} + +export type WorkflowHistoryUpdated = { + readonly history: WorkflowHistory +} + +export function readWorkflowHistoryUpdated(bc: bare.ByteCursor): WorkflowHistoryUpdated { + return { + history: readWorkflowHistory(bc), + } +} + +export function writeWorkflowHistoryUpdated(bc: bare.ByteCursor, x: WorkflowHistoryUpdated): void { + writeWorkflowHistory(bc, x.history) +} + +export type SchedulesUpdated = { + readonly schedules: readonly Schedule[] +} + +export function readSchedulesUpdated(bc: bare.ByteCursor): SchedulesUpdated { + return { + schedules: read9(bc), + } +} + +export function writeSchedulesUpdated(bc: bare.ByteCursor, x: SchedulesUpdated): void { + write9(bc, x.schedules) +} + +export type RpcsListResponse = { + readonly rid: uint + readonly rpcs: readonly string[] +} + +export function readRpcsListResponse(bc: bare.ByteCursor): RpcsListResponse { + return { + rid: bare.readUint(bc), + rpcs: read6(bc), + } +} + +export function writeRpcsListResponse(bc: bare.ByteCursor, x: RpcsListResponse): void { + bare.writeUint(bc, x.rid) + write6(bc, x.rpcs) +} + +export type ConnectionsUpdated = { + readonly connections: readonly Connection[] +} + +export function readConnectionsUpdated(bc: bare.ByteCursor): ConnectionsUpdated { + return { + connections: read4(bc), + } +} + +export function writeConnectionsUpdated(bc: bare.ByteCursor, x: ConnectionsUpdated): void { + write4(bc, x.connections) +} + +export type Error = { + readonly message: string +} + +export function readError(bc: bare.ByteCursor): Error { + return { + message: bare.readString(bc), + } +} + +export function writeError(bc: bare.ByteCursor, x: Error): void { + bare.writeString(bc, x.message) +} + +export type ToClientBody = + | { readonly tag: "StateResponse"; readonly val: StateResponse } + | { readonly tag: "ConnectionsResponse"; readonly val: ConnectionsResponse } + | { readonly tag: "ActionResponse"; readonly val: ActionResponse } + | { readonly tag: "ConnectionsUpdated"; readonly val: ConnectionsUpdated } + | { readonly tag: "QueueUpdated"; readonly val: QueueUpdated } + | { readonly tag: "StateUpdated"; readonly val: StateUpdated } + | { readonly tag: "WorkflowHistoryUpdated"; readonly val: WorkflowHistoryUpdated } + | { readonly tag: "SchedulesUpdated"; readonly val: SchedulesUpdated } + | { readonly tag: "RpcsListResponse"; readonly val: RpcsListResponse } + | { readonly tag: "TraceQueryResponse"; readonly val: TraceQueryResponse } + | { readonly tag: "QueueResponse"; readonly val: QueueResponse } + | { readonly tag: "WorkflowHistoryResponse"; readonly val: WorkflowHistoryResponse } + | { readonly tag: "WorkflowReplayResponse"; readonly val: WorkflowReplayResponse } + | { readonly tag: "SchedulesResponse"; readonly val: SchedulesResponse } + | { readonly tag: "ScheduleHistoryResponse"; readonly val: ScheduleHistoryResponse } + | { readonly tag: "ScheduleDeleteResponse"; readonly val: ScheduleDeleteResponse } + | { readonly tag: "Error"; readonly val: Error } + | { readonly tag: "Init"; readonly val: Init } + | { readonly tag: "DatabaseSchemaResponse"; readonly val: DatabaseSchemaResponse } + | { readonly tag: "DatabaseTableRowsResponse"; readonly val: DatabaseTableRowsResponse } + +export function readToClientBody(bc: bare.ByteCursor): ToClientBody { + const offset = bc.offset + const tag = bare.readU8(bc) + switch (tag) { + case 0: + return { tag: "StateResponse", val: readStateResponse(bc) } + case 1: + return { tag: "ConnectionsResponse", val: readConnectionsResponse(bc) } + case 2: + return { tag: "ActionResponse", val: readActionResponse(bc) } + case 3: + return { tag: "ConnectionsUpdated", val: readConnectionsUpdated(bc) } + case 4: + return { tag: "QueueUpdated", val: readQueueUpdated(bc) } + case 5: + return { tag: "StateUpdated", val: readStateUpdated(bc) } + case 6: + return { tag: "WorkflowHistoryUpdated", val: readWorkflowHistoryUpdated(bc) } + case 7: + return { tag: "SchedulesUpdated", val: readSchedulesUpdated(bc) } + case 8: + return { tag: "RpcsListResponse", val: readRpcsListResponse(bc) } + case 9: + return { tag: "TraceQueryResponse", val: readTraceQueryResponse(bc) } + case 10: + return { tag: "QueueResponse", val: readQueueResponse(bc) } + case 11: + return { tag: "WorkflowHistoryResponse", val: readWorkflowHistoryResponse(bc) } + case 12: + return { tag: "WorkflowReplayResponse", val: readWorkflowReplayResponse(bc) } + case 13: + return { tag: "SchedulesResponse", val: readSchedulesResponse(bc) } + case 14: + return { tag: "ScheduleHistoryResponse", val: readScheduleHistoryResponse(bc) } + case 15: + return { tag: "ScheduleDeleteResponse", val: readScheduleDeleteResponse(bc) } + case 16: + return { tag: "Error", val: readError(bc) } + case 17: + return { tag: "Init", val: readInit(bc) } + case 18: + return { tag: "DatabaseSchemaResponse", val: readDatabaseSchemaResponse(bc) } + case 19: + return { tag: "DatabaseTableRowsResponse", val: readDatabaseTableRowsResponse(bc) } + default: { + bc.offset = offset + throw new bare.BareError(offset, "invalid tag") + } + } +} + +export function writeToClientBody(bc: bare.ByteCursor, x: ToClientBody): void { + switch (x.tag) { + case "StateResponse": { + bare.writeU8(bc, 0) + writeStateResponse(bc, x.val) + break + } + case "ConnectionsResponse": { + bare.writeU8(bc, 1) + writeConnectionsResponse(bc, x.val) + break + } + case "ActionResponse": { + bare.writeU8(bc, 2) + writeActionResponse(bc, x.val) + break + } + case "ConnectionsUpdated": { + bare.writeU8(bc, 3) + writeConnectionsUpdated(bc, x.val) + break + } + case "QueueUpdated": { + bare.writeU8(bc, 4) + writeQueueUpdated(bc, x.val) + break + } + case "StateUpdated": { + bare.writeU8(bc, 5) + writeStateUpdated(bc, x.val) + break + } + case "WorkflowHistoryUpdated": { + bare.writeU8(bc, 6) + writeWorkflowHistoryUpdated(bc, x.val) + break + } + case "SchedulesUpdated": { + bare.writeU8(bc, 7) + writeSchedulesUpdated(bc, x.val) + break + } + case "RpcsListResponse": { + bare.writeU8(bc, 8) + writeRpcsListResponse(bc, x.val) + break + } + case "TraceQueryResponse": { + bare.writeU8(bc, 9) + writeTraceQueryResponse(bc, x.val) + break + } + case "QueueResponse": { + bare.writeU8(bc, 10) + writeQueueResponse(bc, x.val) + break + } + case "WorkflowHistoryResponse": { + bare.writeU8(bc, 11) + writeWorkflowHistoryResponse(bc, x.val) + break + } + case "WorkflowReplayResponse": { + bare.writeU8(bc, 12) + writeWorkflowReplayResponse(bc, x.val) + break + } + case "SchedulesResponse": { + bare.writeU8(bc, 13) + writeSchedulesResponse(bc, x.val) + break + } + case "ScheduleHistoryResponse": { + bare.writeU8(bc, 14) + writeScheduleHistoryResponse(bc, x.val) + break + } + case "ScheduleDeleteResponse": { + bare.writeU8(bc, 15) + writeScheduleDeleteResponse(bc, x.val) + break + } + case "Error": { + bare.writeU8(bc, 16) + writeError(bc, x.val) + break + } + case "Init": { + bare.writeU8(bc, 17) + writeInit(bc, x.val) + break + } + case "DatabaseSchemaResponse": { + bare.writeU8(bc, 18) + writeDatabaseSchemaResponse(bc, x.val) + break + } + case "DatabaseTableRowsResponse": { + bare.writeU8(bc, 19) + writeDatabaseTableRowsResponse(bc, x.val) + break + } + } +} + +export type ToClient = { + readonly body: ToClientBody +} + +export function readToClient(bc: bare.ByteCursor): ToClient { + return { + body: readToClientBody(bc), + } +} + +export function writeToClient(bc: bare.ByteCursor, x: ToClient): void { + writeToClientBody(bc, x.body) +} + +export function encodeToClient(x: ToClient, config?: Partial): Uint8Array { + const fullConfig = config != null ? bare.Config(config) : DEFAULT_CONFIG + const bc = new bare.ByteCursor( + new Uint8Array(fullConfig.initialBufferLength), + fullConfig, + ) + writeToClient(bc, x) + return new Uint8Array(bc.view.buffer, bc.view.byteOffset, bc.offset) +} + +export function decodeToClient(bytes: Uint8Array): ToClient { + const bc = new bare.ByteCursor(bytes, DEFAULT_CONFIG) + const result = readToClient(bc) + if (bc.offset < bc.view.byteLength) { + throw new bare.BareError(bc.offset, "remaining bytes") + } + return result +} + + +function assert(condition: boolean, message?: string): asserts condition { + if (!condition) throw new Error(message ?? "Assertion failed") +} diff --git a/rivetkit-typescript/packages/rivetkit/src/common/encoding.ts b/rivetkit-typescript/packages/rivetkit/src/common/encoding.ts index c29e56cf9e..2f7092fcd2 100644 --- a/rivetkit-typescript/packages/rivetkit/src/common/encoding.ts +++ b/rivetkit-typescript/packages/rivetkit/src/common/encoding.ts @@ -473,27 +473,34 @@ function base64DecodeToArrayBuffer(base64: string): ArrayBuffer { } /** Stringifies with compat for values that BARE & CBOR supports. */ -export function jsonStringifyCompat(input: any): string { - return JSON.stringify(input, (_key, value) => { - if (typeof value === "bigint") { - return [JSON_COMPAT_BIGINT, value.toString()]; - } - if (value instanceof ArrayBuffer) { - return [JSON_COMPAT_ARRAY_BUFFER, base64EncodeArrayBuffer(value)]; - } - if (value instanceof Uint8Array) { - return [JSON_COMPAT_UINT8_ARRAY, base64EncodeUint8Array(value)]; - } - if ( - Array.isArray(value) && - value.length === 2 && - typeof value[0] === "string" && - value[0].startsWith("$") - ) { - return [`$${value[0]}`, value[1]]; - } - return value; - }); +export function jsonStringifyCompat( + input: any, + space?: string | number, +): string { + return JSON.stringify( + input, + (_key, value) => { + if (typeof value === "bigint") { + return [JSON_COMPAT_BIGINT, value.toString()]; + } + if (value instanceof ArrayBuffer) { + return [JSON_COMPAT_ARRAY_BUFFER, base64EncodeArrayBuffer(value)]; + } + if (value instanceof Uint8Array) { + return [JSON_COMPAT_UINT8_ARRAY, base64EncodeUint8Array(value)]; + } + if ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "string" && + value[0].startsWith("$") + ) { + return [`$${value[0]}`, value[1]]; + } + return value; + }, + space, + ); } /** Parses JSON with compat for values that BARE & CBOR supports. */ diff --git a/rivetkit-typescript/packages/rivetkit/src/inspector-tab/mod.ts b/rivetkit-typescript/packages/rivetkit/src/inspector-tab/mod.ts index 552047c312..5d30bdfd65 100644 --- a/rivetkit-typescript/packages/rivetkit/src/inspector-tab/mod.ts +++ b/rivetkit-typescript/packages/rivetkit/src/inspector-tab/mod.ts @@ -69,6 +69,7 @@ export type BuiltinInspectorTabId = | "database" | "state" | "queue" + | "schedules" | "connections" | "console"; diff --git a/rivetkit-typescript/packages/rivetkit/src/inspector/client.browser.ts b/rivetkit-typescript/packages/rivetkit/src/inspector/client.browser.ts index 2642920c45..8f3fa06784 100644 --- a/rivetkit-typescript/packages/rivetkit/src/inspector/client.browser.ts +++ b/rivetkit-typescript/packages/rivetkit/src/inspector/client.browser.ts @@ -4,22 +4,24 @@ import * as v2 from "@/common/bare/generated/inspector/v2"; import * as v3 from "@/common/bare/generated/inspector/v3"; import * as v4 from "@/common/bare/generated/inspector/v4"; import * as v5 from "@/common/bare/generated/inspector/v5"; +import * as v6 from "@/common/bare/generated/inspector/v6"; import { decodeWorkflowHistoryTransport, encodeWorkflowHistoryTransport, } from "@/common/inspector-transport"; -export * from "@/common/bare/generated/inspector/v5"; +export * from "@/common/bare/generated/inspector/v6"; export { decodeWorkflowHistoryTransport, encodeWorkflowHistoryTransport }; export type { WorkflowHistory as TransportWorkflowHistory } from "@/common/bare/transport/v1"; -export const CURRENT_VERSION = 5; +export const CURRENT_VERSION = 6; const EVENTS_DROPPED_ERROR = "inspector.events_dropped"; const WORKFLOW_HISTORY_DROPPED_ERROR = "inspector.workflow_history_dropped"; const QUEUE_DROPPED_ERROR = "inspector.queue_dropped"; const TRACE_DROPPED_ERROR = "inspector.trace_dropped"; const DATABASE_DROPPED_ERROR = "inspector.database_dropped"; +const SCHEDULES_DROPPED_ERROR = "inspector.schedules_dropped"; const v1ToClientToV2 = (v1Data: v1.ToClient): v2.ToClient => { if (v1Data.body.tag === "Init") { @@ -187,12 +189,59 @@ const v5ToClientToV4 = (v5Data: v5.ToClient): v4.ToClient => { return v5Data as unknown as v4.ToClient; }; +const v5ToClientToV6 = (v5Data: v5.ToClient): v6.ToClient => { + if (v5Data.body.tag === "Init") { + return { + body: { + tag: "Init", + val: { ...v5Data.body.val, schedules: [] }, + }, + }; + } + return v5Data as unknown as v6.ToClient; +}; + +const v6ToClientToV5 = (v6Data: v6.ToClient): v5.ToClient => { + if (v6Data.body.tag === "Init") { + const { schedules: _schedules, ...rest } = v6Data.body.val; + return { body: { tag: "Init", val: rest } }; + } + if ( + v6Data.body.tag === "SchedulesUpdated" || + v6Data.body.tag === "SchedulesResponse" || + v6Data.body.tag === "ScheduleHistoryResponse" || + v6Data.body.tag === "ScheduleDeleteResponse" + ) { + return { + body: { + tag: "Error", + val: { message: SCHEDULES_DROPPED_ERROR }, + }, + }; + } + return v6Data as unknown as v5.ToClient; +}; + const v4ToServerToV5 = (v4Data: v4.ToServer): v5.ToServer => v4Data as unknown as v5.ToServer; const v5ToServerToV4 = (v5Data: v5.ToServer): v4.ToServer => v5Data as unknown as v4.ToServer; +const v5ToServerToV6 = (v5Data: v5.ToServer): v6.ToServer => + v5Data as unknown as v6.ToServer; + +const v6ToServerToV5 = (v6Data: v6.ToServer): v5.ToServer => { + if ( + v6Data.body.tag === "SchedulesRequest" || + v6Data.body.tag === "ScheduleHistoryRequest" || + v6Data.body.tag === "ScheduleDeleteRequest" + ) { + throw new Error("Cannot convert v6-only schedule requests to v5"); + } + return v6Data as unknown as v5.ToServer; +}; + const v1ToServerToV2 = (v1Data: v1.ToServer): v2.ToServer => { if ( v1Data.body.tag === "EventsRequest" || @@ -243,7 +292,7 @@ const v4ToServerToV3 = (v4Data: v4.ToServer): v3.ToServer => { return v4Data as unknown as v3.ToServer; }; -export const TO_SERVER_VERSIONED = createVersionedDataHandler({ +export const TO_SERVER_VERSIONED = createVersionedDataHandler({ serializeVersion: (data, version) => { switch (version) { case 1: @@ -255,7 +304,9 @@ export const TO_SERVER_VERSIONED = createVersionedDataHandler({ case 4: return v4.encodeToServer(data as unknown as v4.ToServer); case 5: - return v5.encodeToServer(data); + return v5.encodeToServer(data as unknown as v5.ToServer); + case 6: + return v6.encodeToServer(data); default: throw new Error(`Unknown version ${version}`); } @@ -271,7 +322,9 @@ export const TO_SERVER_VERSIONED = createVersionedDataHandler({ case 4: return v4.decodeToServer(bytes) as unknown as v5.ToServer; case 5: - return v5.decodeToServer(bytes); + return v5.decodeToServer(bytes) as unknown as v6.ToServer; + case 6: + return v6.decodeToServer(bytes); default: throw new Error(`Unknown version ${version}`); } @@ -281,8 +334,10 @@ export const TO_SERVER_VERSIONED = createVersionedDataHandler({ v2ToServerToV3, v3ToServerToV4, v4ToServerToV5, + v5ToServerToV6, ], serializeConverters: () => [ + v6ToServerToV5, v5ToServerToV4, v4ToServerToV3, v3ToServerToV2, @@ -290,7 +345,7 @@ export const TO_SERVER_VERSIONED = createVersionedDataHandler({ ], }); -export const TO_CLIENT_VERSIONED = createVersionedDataHandler({ +export const TO_CLIENT_VERSIONED = createVersionedDataHandler({ serializeVersion: (data, version) => { switch (version) { case 1: @@ -302,7 +357,9 @@ export const TO_CLIENT_VERSIONED = createVersionedDataHandler({ case 4: return v4.encodeToClient(data as unknown as v4.ToClient); case 5: - return v5.encodeToClient(data); + return v5.encodeToClient(data as unknown as v5.ToClient); + case 6: + return v6.encodeToClient(data); default: throw new Error(`Unknown version ${version}`); } @@ -318,7 +375,9 @@ export const TO_CLIENT_VERSIONED = createVersionedDataHandler({ case 4: return v4.decodeToClient(bytes) as unknown as v5.ToClient; case 5: - return v5.decodeToClient(bytes); + return v5.decodeToClient(bytes) as unknown as v6.ToClient; + case 6: + return v6.decodeToClient(bytes); default: throw new Error(`Unknown version ${version}`); } @@ -328,8 +387,10 @@ export const TO_CLIENT_VERSIONED = createVersionedDataHandler({ v2ToClientToV3, v3ToClientToV4, v4ToClientToV5, + v5ToClientToV6, ], serializeConverters: () => [ + v6ToClientToV5, v5ToClientToV4, v4ToClientToV3, v3ToClientToV2, diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts index b77ea8a0d5..6f2fec08c7 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/napi-runtime.ts @@ -15,6 +15,8 @@ import type { RegistryHandle, RuntimeActorConfig, RuntimeBytes, + RuntimeCronFire, + RuntimeCronJobInfo, RuntimeHttpRequest, RuntimeKvEntry, RuntimeKvListOptions, @@ -27,6 +29,7 @@ import type { RuntimeRegistryRouteResponse, RuntimeRequestSaveOpts, RuntimeServeConfig, + RuntimeScheduledEventInfo, RuntimeServerlessRequest, RuntimeServerlessResponseHead, RuntimeServerlessStreamCallback, @@ -847,28 +850,115 @@ export class NapiCoreRuntime implements CoreRuntime { await asNativeActorContext(ctx).queue().reset(); } - actorScheduleAfter( + async actorScheduleAfter( ctx: ActorContextHandle, durationMs: number, actionName: string, args: RuntimeBytes, - ): void { - asNativeActorContext(ctx) + ): Promise { + return await asNativeActorContext(ctx) .schedule() .after(durationMs, actionName, toNapiBuffer(args)); } - actorScheduleAt( + async actorScheduleAt( ctx: ActorContextHandle, timestampMs: number, actionName: string, args: RuntimeBytes, - ): void { - asNativeActorContext(ctx) + ): Promise { + return await asNativeActorContext(ctx) .schedule() .at(timestampMs, actionName, toNapiBuffer(args)); } + async actorScheduleCancel(ctx: ActorContextHandle, id: string) { + return await asNativeActorContext(ctx).schedule().cancel(id); + } + + async actorScheduleGet( + ctx: ActorContextHandle, + id: string, + ): Promise { + return ( + (await asNativeActorContext(ctx).schedule().get(id)) ?? undefined + ); + } + + async actorScheduleList(ctx: ActorContextHandle) { + return await asNativeActorContext(ctx).schedule().list(); + } + + async actorCronSet( + ctx: ActorContextHandle, + name: string, + expression: string, + timezone: string | undefined, + actionName: string, + args: RuntimeBytes, + maxHistory: number | undefined, + ) { + await asNativeActorContext(ctx) + .schedule() + .cronSet( + name, + expression, + timezone, + actionName, + toNapiBuffer(args), + maxHistory, + ); + } + + async actorCronEvery( + ctx: ActorContextHandle, + name: string, + intervalMs: number, + actionName: string, + args: RuntimeBytes, + maxHistory: number | undefined, + ) { + await asNativeActorContext(ctx) + .schedule() + .cronEvery( + name, + intervalMs, + actionName, + toNapiBuffer(args), + maxHistory, + ); + } + + async actorCronGet( + ctx: ActorContextHandle, + name: string, + ): Promise { + return ((await asNativeActorContext(ctx).schedule().cronGet(name)) ?? + undefined) as RuntimeCronJobInfo | undefined; + } + + async actorCronList( + ctx: ActorContextHandle, + ): Promise { + return (await asNativeActorContext(ctx) + .schedule() + .cronList()) as RuntimeCronJobInfo[]; + } + + async actorCronDelete(ctx: ActorContextHandle, name: string) { + return await asNativeActorContext(ctx).schedule().cronDelete(name); + } + + async actorCronHistory( + ctx: ActorContextHandle, + name: string, + limit: number | undefined, + ): Promise { + return (await asNativeActorContext(ctx) + .schedule() + .cronHistory(name, limit)) as RuntimeCronFire[]; + } + connId(conn: ConnHandle): string { return asNativeConn(conn).id(); } diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index 4e47fabee4..74730051b7 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -6,6 +6,13 @@ import { getRunFunction, getRunInspectorConfig, RAW_STATE_SYMBOL, + type ActorCron, + type ActorCronEveryOptions, + type ActorCronSetOptions, + type ActorSchedule, + type CronFire, + type CronJobInfo, + type ScheduledEventInfo, type WorkflowInspectorConfig, } from "@/actor/config"; import type { AnyActorDefinition } from "@/actor/definition"; @@ -79,7 +86,11 @@ import type { RuntimeBytes, RuntimeHttpResponse, RuntimeInspectorTabEntry, + RuntimeCronFire, + RuntimeCronJobInfo, RuntimeQueueMessage, + RuntimeScheduledEventInfo, + RuntimeScheduledFireInfo, RuntimeServeConfig, RuntimeStateDeltaPayload, WebSocketHandle, @@ -1333,7 +1344,7 @@ class NativeConnAdapter { } } -class NativeScheduleAdapter { +class NativeScheduleAdapter implements ActorSchedule { #runtime: CoreRuntime; #ctx: ActorContextHandle; @@ -1346,14 +1357,12 @@ class NativeScheduleAdapter { duration: number, action: string, ...args: unknown[] - ): Promise { - callNativeSync(() => - this.#runtime.actorScheduleAfter( - this.#ctx, - duration, - action, - encodeValue(args), - ), + ): Promise { + return await this.#runtime.actorScheduleAfter( + this.#ctx, + duration, + action, + encodeValue(args), ); } @@ -1361,18 +1370,131 @@ class NativeScheduleAdapter { timestamp: number, action: string, ...args: unknown[] - ): Promise { - callNativeSync(() => - this.#runtime.actorScheduleAt( - this.#ctx, - timestamp, - action, - encodeValue(args), - ), + ): Promise { + return await this.#runtime.actorScheduleAt( + this.#ctx, + timestamp, + action, + encodeValue(args), + ); + } + + async cancel(id: string): Promise { + return await this.#runtime.actorScheduleCancel(this.#ctx, id); + } + + async get(id: string): Promise { + const event = await this.#runtime.actorScheduleGet(this.#ctx, id); + return event ? decodeScheduledEventInfo(event) : undefined; + } + + async list(): Promise { + return (await this.#runtime.actorScheduleList(this.#ctx)).map( + decodeScheduledEventInfo, ); } } +class NativeCronAdapter implements ActorCron { + #runtime: CoreRuntime; + #ctx: ActorContextHandle; + + constructor(runtime: CoreRuntime, ctx: ActorContextHandle) { + this.#runtime = runtime; + this.#ctx = ctx; + } + + async set(options: ActorCronSetOptions): Promise { + await this.#runtime.actorCronSet( + this.#ctx, + options.name, + options.expression, + options.timezone, + options.action, + encodeValue(options.args ?? []), + options.maxHistory, + ); + } + + async every(options: ActorCronEveryOptions): Promise { + await this.#runtime.actorCronEvery( + this.#ctx, + options.name, + options.intervalMs, + options.action, + encodeValue(options.args ?? []), + options.maxHistory, + ); + } + + async get(name: string): Promise { + const job = await this.#runtime.actorCronGet(this.#ctx, name); + return job ? decodeCronJobInfo(job) : undefined; + } + + async list(): Promise { + return (await this.#runtime.actorCronList(this.#ctx)).map( + decodeCronJobInfo, + ); + } + + async delete(name: string): Promise { + return await this.#runtime.actorCronDelete(this.#ctx, name); + } + + async history( + name: string, + options?: { limit?: number }, + ): Promise { + return ( + await this.#runtime.actorCronHistory( + this.#ctx, + name, + options?.limit, + ) + ).map(decodeCronFire); + } +} + +function decodeScheduledEventInfo( + event: RuntimeScheduledEventInfo, +): ScheduledEventInfo { + return { + id: event.id, + action: event.action, + args: decodeArgs(event.args), + runAt: event.runAt, + }; +} + +function decodeCronJobInfo(job: RuntimeCronJobInfo): CronJobInfo { + const base = { + name: job.name, + action: job.action, + args: decodeArgs(job.args), + nextRunAt: job.nextRunAt, + lastRunAt: job.lastRunAt, + maxHistory: job.maxHistory, + }; + if (job.kind === "cron") { + return { + ...base, + kind: "cron", + expression: job.expression as string, + timezone: job.timezone as string, + }; + } + return { + ...base, + kind: "every", + intervalMs: job.intervalMs as number, + }; +} + +function decodeCronFire(fire: RuntimeCronFire): CronFire { + return { ...fire }; +} + class NativeKvAdapter { #runtime: CoreRuntime; #ctx: ActorContextHandle; @@ -2428,6 +2550,7 @@ export class ActorContextHandleAdapter { #client?: AnyClient; #clientFactory?: () => AnyClient; #connMap?: NativeConnectionMap; + #cron?: NativeCronAdapter; #databaseProvider?: Exclude; #db?: unknown; #dispatchCancelToken?: CancellationTokenHandle; @@ -2602,6 +2725,13 @@ export class ActorContextHandleAdapter { return this.#schedule; } + get cron(): NativeCronAdapter { + if (!this.#cron) { + this.#cron = new NativeCronAdapter(this.#runtime, this.#ctx); + } + return this.#cron; + } + get actorId(): string { return callNativeSync(() => this.#runtime.actorId(this.#ctx)); } @@ -3380,6 +3510,7 @@ function buildActorConfig( | number | undefined, maxQueueSize: options.maxQueueSize as number | undefined, + maxSchedules: options.maxSchedules as number | undefined, maxQueueMessageSize: options.maxQueueMessageSize as number | undefined, maxIncomingMessageSize: registryConfig.maxIncomingMessageSize as | number @@ -4728,10 +4859,11 @@ export function buildNativeFactory( conn: ConnHandle | null; name: string; args: RuntimeBytes; + scheduledFire?: RuntimeScheduledFireInfo; cancelToken?: CancellationTokenHandle; }, ) => { - const { ctx, conn, args, cancelToken } = + const { ctx, conn, args, scheduledFire, cancelToken } = unwrapTsfnPayload(error, payload); const actorCtx = conn != null @@ -4746,6 +4878,7 @@ export function buildNativeFactory( name, decodeArgs(args), ), + ...(scheduledFire ? [scheduledFire] : []), ), ); } finally { diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts index 0a75a5b243..3c8d9b3cc2 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/runtime.ts @@ -86,6 +86,50 @@ export interface RuntimeQueueSendResult { response?: RuntimeBytes; } +export interface RuntimeScheduledEventInfo { + id: string; + action: string; + args: RuntimeBytes; + runAt: number; +} + +export interface RuntimeCronJobInfo { + name: string; + kind: "cron" | "every"; + action: string; + args: RuntimeBytes; + nextRunAt: number; + lastRunAt?: number; + expression?: string; + timezone?: string; + intervalMs?: number; + maxHistory: number; +} + +export interface RuntimeScheduleErrorInfo { + group: string; + code: string; + message: string; + metadata?: unknown; +} + +export interface RuntimeCronFire { + action: string; + scheduledAt: number; + firedAt: number; + finishedAt?: number; + result: "running" | "ok" | "error" | "skipped"; + error?: RuntimeScheduleErrorInfo; +} + +export interface RuntimeScheduledFireInfo { + kind: "at" | "cron" | "every"; + id: string; + name?: string; + scheduledAt: number; + firedAt: number; +} + export interface RuntimeQueueNextBatchOptions { names?: string[]; count?: number; @@ -253,6 +297,7 @@ export interface RuntimeActorConfig { connectionLivenessTimeoutMs?: number; connectionLivenessIntervalMs?: number; maxQueueSize?: number; + maxSchedules?: number; maxQueueMessageSize?: number; maxIncomingMessageSize?: number; maxOutgoingMessageSize?: number; @@ -611,13 +656,49 @@ export interface CoreRuntime { durationMs: number, actionName: string, args: RuntimeBytes, - ): void; + ): Promise; actorScheduleAt( ctx: ActorContextHandle, timestampMs: number, actionName: string, args: RuntimeBytes, - ): void; + ): Promise; + actorScheduleCancel(ctx: ActorContextHandle, id: string): Promise; + actorScheduleGet( + ctx: ActorContextHandle, + id: string, + ): Promise; + actorScheduleList( + ctx: ActorContextHandle, + ): Promise; + actorCronSet( + ctx: ActorContextHandle, + name: string, + expression: string, + timezone: string | undefined, + actionName: string, + args: RuntimeBytes, + maxHistory: number | undefined, + ): Promise; + actorCronEvery( + ctx: ActorContextHandle, + name: string, + intervalMs: number, + actionName: string, + args: RuntimeBytes, + maxHistory: number | undefined, + ): Promise; + actorCronGet( + ctx: ActorContextHandle, + name: string, + ): Promise; + actorCronList(ctx: ActorContextHandle): Promise; + actorCronDelete(ctx: ActorContextHandle, name: string): Promise; + actorCronHistory( + ctx: ActorContextHandle, + name: string, + limit: number | undefined, + ): Promise; connId(conn: ConnHandle): string; connParams(conn: ConnHandle): RuntimeBytes; diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts b/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts index 9eae20864a..bcfeda04e1 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/wasm-runtime.ts @@ -33,9 +33,12 @@ import type { RuntimeQueueNextBatchOptions, RuntimeQueueTryNextBatchOptions, RuntimeQueueWaitOptions, + RuntimeCronFire, + RuntimeCronJobInfo, RuntimeRegistryRouteResponse, RuntimeRequestSaveOpts, RuntimeServeConfig, + RuntimeScheduledEventInfo, RuntimeServerlessRequest, RuntimeServerlessResponseHead, RuntimeServerlessStreamCallback, @@ -941,24 +944,136 @@ export class WasmCoreRuntime implements CoreRuntime { await callHandleAsync(queue, "reset"); } - actorScheduleAfter( + async actorScheduleAfter( ctx: ActorContextHandle, durationMs: number | bigint, actionName: string, args: RuntimeBytes, - ): void { + ): Promise { const schedule = childHandle(asWasmActorContext(ctx), "schedule"); - callHandle(schedule, "after", wasmNumber(durationMs), actionName, args); + return await callHandleAsync( + schedule, + "after", + wasmNumber(durationMs), + actionName, + args, + ); } - actorScheduleAt( + async actorScheduleAt( ctx: ActorContextHandle, timestampMs: number | bigint, actionName: string, args: RuntimeBytes, - ): void { + ): Promise { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + return await callHandleAsync( + schedule, + "at", + wasmNumber(timestampMs), + actionName, + args, + ); + } + + async actorScheduleCancel(ctx: ActorContextHandle, id: string) { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + return await callHandleAsync(schedule, "cancel", id); + } + + async actorScheduleGet(ctx: ActorContextHandle, id: string) { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + return await callHandleAsync( + schedule, + "get", + id, + ); + } + + async actorScheduleList(ctx: ActorContextHandle) { const schedule = childHandle(asWasmActorContext(ctx), "schedule"); - callHandle(schedule, "at", wasmNumber(timestampMs), actionName, args); + return await callHandleAsync( + schedule, + "list", + ); + } + + async actorCronSet( + ctx: ActorContextHandle, + name: string, + expression: string, + timezone: string | undefined, + actionName: string, + args: RuntimeBytes, + maxHistory: number | undefined, + ) { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + await callHandleAsync( + schedule, + "cronSet", + name, + expression, + timezone, + actionName, + args, + maxHistory, + ); + } + + async actorCronEvery( + ctx: ActorContextHandle, + name: string, + intervalMs: number, + actionName: string, + args: RuntimeBytes, + maxHistory: number | undefined, + ) { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + await callHandleAsync( + schedule, + "cronEvery", + name, + intervalMs, + actionName, + args, + maxHistory, + ); + } + + async actorCronGet(ctx: ActorContextHandle, name: string) { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + return await callHandleAsync( + schedule, + "cronGet", + name, + ); + } + + async actorCronList(ctx: ActorContextHandle) { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + return await callHandleAsync( + schedule, + "cronList", + ); + } + + async actorCronDelete(ctx: ActorContextHandle, name: string) { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + return await callHandleAsync(schedule, "cronDelete", name); + } + + async actorCronHistory( + ctx: ActorContextHandle, + name: string, + limit: number | undefined, + ) { + const schedule = childHandle(asWasmActorContext(ctx), "schedule"); + return await callHandleAsync( + schedule, + "cronHistory", + name, + limit, + ); } connId(conn: ConnHandle): string { diff --git a/rivetkit-typescript/packages/rivetkit/src/utils.ts b/rivetkit-typescript/packages/rivetkit/src/utils.ts index 0613cbd091..c521492c67 100644 --- a/rivetkit-typescript/packages/rivetkit/src/utils.ts +++ b/rivetkit-typescript/packages/rivetkit/src/utils.ts @@ -10,6 +10,9 @@ export { stringifyError }; /** @experimental */ export { assertUnreachable }; +/** @experimental */ +export { jsonParseCompat, jsonStringifyCompat } from "./common/encoding"; + /** * Joins multiple abort signals into one. * diff --git a/rivetkit-typescript/packages/rivetkit/tests/cron-api.test.ts b/rivetkit-typescript/packages/rivetkit/tests/cron-api.test.ts new file mode 100644 index 0000000000..323a066d43 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/cron-api.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test, vi } from "vitest"; +import { ActorContextHandleAdapter } from "@/registry/native"; +import { InstanceActorOptionsSchema } from "@/actor/config"; +import type { + ActorContextHandle, + CoreRuntime, + RuntimeCronJobInfo, + RuntimeScheduledEventInfo, +} from "@/registry/runtime"; +import { decodeCborCompat } from "@/serde"; + +const context = {} as ActorContextHandle; + +function runtimeWithScheduleSpies() { + const actorCronSet = vi.fn(async (..._args: unknown[]) => {}); + const actorCronEvery = vi.fn(async (..._args: unknown[]) => {}); + const actorScheduleAfter = vi.fn( + async (..._args: unknown[]) => "one-shot-id", + ); + const actorScheduleGet = vi.fn( + async (..._args: unknown[]): Promise => ({ + id: "one-shot-id", + action: "remind", + args: new Uint8Array([0x81, 0x64, 0x75, 0x73, 0x65, 0x72]), + runAt: 123, + }), + ); + const actorCronGet = vi.fn( + async (..._args: unknown[]): Promise => ({ + name: "daily-report", + kind: "cron", + action: "generateReport", + args: new Uint8Array([ + 0x81, + 0x6a, + ...new TextEncoder().encode("operations"), + ]), + nextRunAt: 456, + expression: "0 9 * * *", + timezone: "America/Los_Angeles", + maxHistory: 100, + }), + ); + const runtime = { + kind: "napi", + actorId: () => "cron-api-test", + actorCronSet, + actorCronEvery, + actorCronGet, + actorScheduleAfter, + actorScheduleGet, + } as unknown as CoreRuntime; + return { + runtime, + actorCronSet, + actorCronEvery, + actorCronGet, + actorScheduleAfter, + actorScheduleGet, + }; +} + +describe("actor scheduling public API", () => { + test("schedule cap defaults to 1000 and validates nonnegative integers", () => { + expect(InstanceActorOptionsSchema.parse({}).maxSchedules).toBe(1_000); + expect( + InstanceActorOptionsSchema.parse({ maxSchedules: 25 }).maxSchedules, + ).toBe(25); + expect(() => + InstanceActorOptionsSchema.parse({ maxSchedules: -1 }), + ).toThrow(); + expect(() => + InstanceActorOptionsSchema.parse({ maxSchedules: 1.5 }), + ).toThrow(); + }); + + test("set and every accept option objects and encode args arrays", async () => { + const { runtime, actorCronSet, actorCronEvery } = + runtimeWithScheduleSpies(); + const c = new ActorContextHandleAdapter(runtime, context); + + await c.cron.set({ + name: "daily-report", + expression: "0 9 * * *", + timezone: "America/Los_Angeles", + action: "generateReport", + args: ["operations"], + maxHistory: 20, + }); + await c.cron.every({ + name: "refresh-cache", + intervalMs: 60_000, + action: "refreshCache", + }); + + const setCall = actorCronSet.mock.calls[0]; + expect(setCall.slice(1, 5)).toEqual([ + "daily-report", + "0 9 * * *", + "America/Los_Angeles", + "generateReport", + ]); + expect(decodeCborCompat(setCall[5] as Uint8Array)).toEqual([ + "operations", + ]); + expect(setCall[6]).toBe(20); + + const everyCall = actorCronEvery.mock.calls[0]; + expect(everyCall.slice(1, 4)).toEqual([ + "refresh-cache", + 60_000, + "refreshCache", + ]); + expect(decodeCborCompat(everyCall[4] as Uint8Array)).toEqual([]); + expect(everyCall[5]).toBeUndefined(); + }); + + test("one-shot and inspection values round-trip through the adapter", async () => { + const { runtime, actorScheduleAfter, actorScheduleGet, actorCronGet } = + runtimeWithScheduleSpies(); + const c = new ActorContextHandleAdapter(runtime, context); + + await expect(c.schedule.after(5_000, "remind", "user")).resolves.toBe( + "one-shot-id", + ); + expect(decodeCborCompat(actorScheduleAfter.mock.calls[0][3])).toEqual([ + "user", + ]); + await expect(c.schedule.get("one-shot-id")).resolves.toMatchObject({ + id: "one-shot-id", + action: "remind", + args: ["user"], + }); + await expect(c.cron.get("daily-report")).resolves.toMatchObject({ + name: "daily-report", + kind: "cron", + args: ["operations"], + }); + expect(actorScheduleGet).toHaveBeenCalledWith(context, "one-shot-id"); + expect(actorCronGet).toHaveBeenCalledWith(context, "daily-report"); + }); +}); diff --git a/rivetkit-typescript/packages/rivetkit/tests/inspector-versioned.test.ts b/rivetkit-typescript/packages/rivetkit/tests/inspector-versioned.test.ts index e4c68a3267..feefd9f53d 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/inspector-versioned.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/inspector-versioned.test.ts @@ -4,6 +4,7 @@ import * as v2 from "@/common/bare/generated/inspector/v2"; import * as v3 from "@/common/bare/generated/inspector/v3"; import * as v4 from "@/common/bare/generated/inspector/v4"; import * as v5 from "@/common/bare/generated/inspector/v5"; +import * as v6 from "@/common/bare/generated/inspector/v6"; import type { WorkflowHistory } from "@/common/bare/transport/v1"; import { decodeWorkflowHistoryTransport, @@ -11,7 +12,7 @@ import { } from "@/common/inspector-transport"; import { loadNapiRuntime, type NapiCoreRuntime } from "@/registry/napi-runtime"; -const INSPECTOR_CURRENT_VERSION = 5; +const INSPECTOR_CURRENT_VERSION = 6; let runtime: NapiCoreRuntime; beforeAll(async () => { @@ -28,8 +29,8 @@ function toBuffer(value: ArrayBuffer | Uint8Array): Buffer { ); } -function decodeRequest(bytes: Uint8Array, version: number): v5.ToServer { - return v5.decodeToServer( +function decodeRequest(bytes: Uint8Array, version: number): v6.ToServer { + return v6.decodeToServer( new Uint8Array( runtime.decodeInspectorRequest(toBuffer(bytes), version), ), @@ -37,23 +38,23 @@ function decodeRequest(bytes: Uint8Array, version: number): v5.ToServer { } function encodeResponse( - message: v5.ToClient, - version: 1 | 2 | 3 | 4, + message: v6.ToClient, + version: 1 | 2 | 3 | 4 | 5, ): Uint8Array { return new Uint8Array( runtime.encodeInspectorResponse( - toBuffer(v5.encodeToClient(message)), + toBuffer(v6.encodeToClient(message)), version, ), ); } describe("inspector versioned protocol", () => { - test("tracks v5 as the current inspector wire version", () => { - expect(INSPECTOR_CURRENT_VERSION).toBe(5); + test("tracks v6 as the current inspector wire version", () => { + expect(INSPECTOR_CURRENT_VERSION).toBe(6); }); - test("decodes a shared request shape from versions 1-4 into the current schema", () => { + test("decodes a shared request shape from versions 1-5 into the current schema", () => { const request = { body: { tag: "ActionRequest" as const, @@ -65,7 +66,7 @@ describe("inspector versioned protocol", () => { }, }; - for (const version of [1, 2, 3, 4]) { + for (const version of [1, 2, 3, 4, 5]) { const bytes = version === 1 ? v1.encodeToServer(request as unknown as v1.ToServer) @@ -75,7 +76,9 @@ describe("inspector versioned protocol", () => { ? v3.encodeToServer( request as unknown as v3.ToServer, ) - : v4.encodeToServer(request); + : version === 4 + ? v4.encodeToServer(request) + : v5.encodeToServer(request); const decoded = decodeRequest(bytes, version); expect(decoded).toEqual(request); @@ -96,6 +99,7 @@ describe("inspector versioned protocol", () => { workflowHistory: buffer("workflow"), isWorkflowEnabled: true, tabConfig: [], + schedules: [], }, }, }; @@ -117,6 +121,26 @@ describe("inspector versioned protocol", () => { }); }); + test("downgrades schedule updates into explicit errors before v6", () => { + const decoded = v5.decodeToClient( + encodeResponse( + { + body: { + tag: "SchedulesUpdated", + val: { schedules: [] }, + }, + }, + 5, + ), + ); + expect(decoded).toEqual({ + body: { + tag: "Error", + val: { message: "inspector.schedules_dropped" }, + }, + }); + }); + test("downgrades dropped v1 queue updates into explicit errors", () => { const decoded = v1.decodeToClient( encodeResponse( diff --git a/rivetkit-typescript/packages/rivetkit/tests/package-surface.test.ts b/rivetkit-typescript/packages/rivetkit/tests/package-surface.test.ts index 319a920630..f76029ee17 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/package-surface.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/package-surface.test.ts @@ -35,6 +35,7 @@ import { type TransportWorkflowHistory, } from "rivetkit/inspector/client"; import { setupTest } from "rivetkit/test"; +import { jsonParseCompat, jsonStringifyCompat } from "rivetkit/utils"; import { describe, expect, test } from "vitest"; import packageJson from "../package.json" with { type: "json" }; @@ -98,9 +99,10 @@ describe("package surface", () => { expect(rawDb).toBeTypeOf("function"); expect(drizzleDb).toBeTypeOf("function"); expect(defineConfig).toBeTypeOf("function"); - expect(CURRENT_VERSION).toBe(5); + expect(CURRENT_VERSION).toBe(6); expect(TO_CLIENT_VERSIONED).toBeDefined(); expect(TO_SERVER_VERSIONED).toBeDefined(); + expect(jsonParseCompat(jsonStringifyCompat(1n))).toBe(1n); const history: TransportWorkflowHistory | null = null; expect(history).toBeNull(); diff --git a/rivetkit-typescript/packages/rivetkit/tests/runtime-parity.test.ts b/rivetkit-typescript/packages/rivetkit/tests/runtime-parity.test.ts index 12e508e1d3..f0ed09a7cf 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/runtime-parity.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/runtime-parity.test.ts @@ -44,6 +44,13 @@ type NativeCallbacks = { conn: null; name: string; args: Uint8Array; + scheduledFire?: { + kind: "at" | "cron" | "every"; + id: string; + name?: string; + scheduledAt: number; + firedAt: number; + }; cancelToken: FakeCancellationToken; }, ) => Promise @@ -413,6 +420,43 @@ async function invokePromotedStatus( } describe("CoreRuntime NAPI and wasm parity", () => { + test("scheduled fire metadata is appended after validated action args", async () => { + const runtimeCase = createRuntimeCase("napi"); + let received: unknown[] = []; + const definition = actor({ + state: {}, + actions: { + report: async (_c, team: string, fire?: unknown) => { + received = [team, fire]; + }, + }, + }); + const factory = buildNativeFactory( + runtimeCase.runtime, + registryConfig(definition), + definition, + ) as unknown as FakeActorFactory; + const ctx = new FakeActorContext(runtimeCase.scenario); + const fire = { + kind: "cron" as const, + id: "daily-report", + name: "daily-report", + scheduledAt: 100, + firedAt: 125, + }; + + await factory.callbacks.actions.report(null, { + ctx, + conn: null, + name: "report", + args: encodeValue(["operations"]), + scheduledFire: fire, + cancelToken: new FakeCancellationToken(), + }); + + expect(received).toEqual(["operations", fire]); + }); + test.each([ "napi", "wasm", diff --git a/website/src/content/cookbook/cron-jobs.mdx b/website/src/content/cookbook/cron-jobs.mdx index cb8ffe37cb..fad1fa4c8e 100644 --- a/website/src/content/cookbook/cron-jobs.mdx +++ b/website/src/content/cookbook/cron-jobs.mdx @@ -1,128 +1,68 @@ --- title: "Cron Jobs and Scheduled Tasks" -description: "Durable cron jobs with Rivet Actors: schedule.after and schedule.at timers survive restarts and crashes, plus re-arming recurring jobs and idempotent handlers." +description: "Patterns for durable one-shot, calendar, and fixed-interval work on Rivet Actors." templates: ["scheduling"] --- -Patterns for running durable cron jobs and scheduled tasks on Rivet Actors. Actor schedules are persistent timers owned by the engine, so a job keeps its deadline through actor sleep, restarts, upgrades, deploys, and crashes. +Rivet Actor schedules are durable actor-local timers. They survive actor sleep, restarts, upgrades, deploys, and crashes without a separate cron service. -## Starter Code +## Choose a schedule type -Start from the working [Scheduling example on GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/scheduling). It implements a reminder service with one-shot timers, a React frontend, and live trigger events. - -## The Scheduling API - -The full API is documented in [Scheduling](/docs/actors/schedule). There are two methods, both available on the actor context: - -| Method | Behavior | +| API | Use it for | | --- | --- | -| `c.schedule.after(duration, actionName, ...args)` | Runs the named action after `duration` milliseconds. | -| `c.schedule.at(timestamp, actionName, ...args)` | Runs the named action at an exact epoch timestamp in milliseconds. | +| `c.schedule.after(delayMs, action, ...args)` | One-time work after a relative delay. | +| `c.schedule.at(timestamp, action, ...args)` | One-time work at an exact Unix timestamp in milliseconds. | +| `c.cron.set({ ... })` | Named calendar recurrence in an IANA timezone. | +| `c.cron.every({ ... })` | Named fixed intervals of at least 5 seconds. | -Key properties: +All callbacks are ordinary actions on the same actor. Keep the action name fixed in your code rather than accepting an arbitrary action name from a client. -- **Durable**: The schedule is persisted by the engine and the timer survives actor sleep, restart, upgrade, and crash. If the actor is asleep at the deadline, the engine wakes it to run the action. See [Lifecycle](/docs/actors/lifecycle). -- **Plain actions as callbacks**: The scheduled callback is an ordinary [action](/docs/actors/actions) on the same actor, invoked by name. Arguments after the action name are forwarded positionally, for example `c.schedule.after(delayMs, "triggerReminder", reminder.id)`. -- **No cancellation API**: Rivet does not currently support canceling a scheduled action. The pattern is a tombstone guard: remove the entry from [state](/docs/actors/state) and have the scheduled action no-op when it cannot find its entry. The example's `cancelReminder` and `triggerReminder` actions implement exactly this. +See [Schedule & Cron](/docs/actors/schedule) for the full API, history, cancellation, failure behavior, and limits. -## Recurring Jobs Via Re-Arm +## Calendar job -`c.schedule` is one-shot, so recurring jobs are built by having the scheduled action re-arm itself at the end of each run: +Use `cron.set` instead of manually re-arming a one-shot action: -Arm the first run from `onCreate` or a setup action; after that, the action keeps the chain alive by rescheduling itself. +Install fixed background jobs in `onCreate` so setup runs once per actor. The job name remains an upsert key, so a later `cron.set` call updates the existing job rather than creating a duplicate. `cron.set` also handles timezone and daylight-saving transitions. -Re-arming with `after` measures the next run from the end of the current one, so the cadence drifts later by the job's runtime on every cycle. If runs must stay aligned to a fixed cadence, re-arm with `c.schedule.at(c.state.lastRunAt + DAY_MS, "runReport")` instead. +## Fixed-interval job -The Scheduling example itself only uses one-shot reminders. A real re-arm implementation lives in the [idle world actor](https://github.com/rivet-dev/rivet/tree/main/examples/multiplayer-game-patterns/src/actors/idle/), where the `collectProduction` action credits production, updates `lastCollectedAt`, and calls a `scheduleCollection` helper that re-arms with `c.schedule.after(delayMs, "collectProduction", { buildingId })`. It also shows catch-up handling: if the action runs late, it computes how many whole intervals elapsed since the last run and credits them in one batch before re-arming. +Use `cron.every` for frequent work such as presence sweeps or cache refreshes: -For multi-step jobs that need retries and progress tracking inside a single run, consider [Workflows](/docs/actors/workflows) instead of chaining schedules. - -## Durability Comparison - -| Approach | Timer Durability | Horizontal Scaling | Deploys And Restarts | -| --- | --- | --- | --- | -| `setTimeout` / `setInterval` | In-process memory only | Every replica arms its own timer, so jobs run once per instance | All pending timers are lost on restart or crash | -| `node-cron` and similar libraries | In-process memory only | Every instance runs the job unless you add external locking | Schedule resets on deploy; runs missed during downtime are skipped | -| External cron service | Lives outside your app | Needs a public HTTP endpoint plus its own dedupe and retry state | Survives your deploys but is separate infrastructure to operate | -| Rivet Actor scheduling | Persisted by the engine as a durable timer | Exactly one actor per key, so the timer is armed once rather than once per replica | Survives actor sleep, restart, upgrade, and crash | +```ts +await c.cron.every({ + name: "presence-sweep", + intervalMs: 15_000, // Minimum 5 seconds. + action: "sweepPresence", + maxHistory: 25, +}); +``` -## Idempotency +Intervals remain anchored to scheduled deadlines rather than drifting by the action's runtime. If a previous run is still active, the overlapping occurrence is skipped. -A scheduled action can fire more than you expect: a crash between doing the work and re-arming can cause the action to run again, and because schedules cannot be cancelled, an action can fire for an entry that was already removed. Design handlers so a duplicate firing is harmless: +## Cancellation and updates -- **Store a run marker in state**: Keep a `lastRunAt` timestamp or a sequence number in actor state and update it inside the action. On each firing, compute elapsed time since the marker and skip or batch accordingly. The idle world actor's `collectProduction` does this with `lastCollectedAt` and whole-interval batching. -- **Tombstone guard for cancelled entries**: The example's `triggerReminder` looks the reminder up in `c.state.reminders` first and returns with a warning if it is gone, so a fire-after-cancel is a safe no-op. -- **Keep work and marker updates in the same action**: Actor state writes are persisted with the action, so updating the marker in the same handler that does the work keeps the two consistent. +Keep the ID returned by a one-shot schedule when it may need cancellation: -## Topology +```ts +const id = await c.schedule.after(60_000, "expireSession", sessionId); +await c.schedule.cancel(id); +``` -| Topology | Use When | Example Key | -| --- | --- | --- | -| Singleton job actor | One global job such as a nightly report or cleanup pass | `job["daily-report"]` | -| Actor per scheduled entity | Per-user or per-resource timers such as reminders, trials, or billing periods | `reminder[userId]` | +Recurring jobs are managed by name: -The Scheduling example uses a single shared `reminderActor["main"]` key for demo simplicity. For production reminder systems, prefer one actor per user so timers, state, and load are isolated per entity. See [Keys](/docs/actors/keys). +```ts +await c.cron.delete("presence-sweep"); +``` -## Reminder Service Example +Calling `cron.set` or `cron.every` again with the same name replaces its configuration. -| Topic | Summary | -| --- | --- | -| Scheduling | One-shot timers armed with `c.schedule.after(delayMs, "triggerReminder", reminder.id)` or `c.schedule.at(timestamp, "triggerReminder", reminder.id)`. | -| State | JSON state holding `reminders` and `completedCount`; the scheduled action mutates state when it fires. | -| Events | `triggerReminder` broadcasts a `reminderTriggered` event to all connected clients. See [Events](/docs/actors/events). | -| Cancellation | `cancelReminder` only removes the reminder from state; the scheduled action may still fire and no-ops via a state lookup guard. | - -**Actors** - - - - -- **Key**: `reminderActor["main"]` -- **Responsibility**: Stores reminders in persistent state, arms a future self-action per reminder via `c.schedule`, marks reminders completed when the scheduled action fires, and broadcasts `reminderTriggered` to connected clients. -- **Actions** - - `scheduleReminder` - - `scheduleReminderAt` - - `triggerReminder` - - `getReminders` - - `cancelReminder` - - `getStats` -- **Queues** - - None -- **State** - - JSON - - `reminders` - - `completedCount` - - - - -**Lifecycle** - -```mermaid -sequenceDiagram - participant C as Client - participant R as reminderActor - participant E as Engine - - C->>R: scheduleReminder(message, delayMs) - R->>E: schedule.after(delayMs, triggerReminder, id) - Note over E: schedule persisted + alarm armed - R-->>C: reminder - Note over R: actor sleeps - E->>R: alarm fires, actor wakes - Note over R: triggerReminder(id) runs - R-->>C: reminderTriggered event - Note over R: recurring jobs re-arm here with schedule.after -``` +## Failure and idempotency -## Security Checklist +Keep scheduled actions idempotent when duplicate work would be harmful. See [Execution behavior](/docs/actors/schedule#execution-behavior) for retry behavior and workflow guidance. -The example is intentionally open: any client can connect to the shared `["main"]` key and schedule or cancel anything. Treat all of the following as required extensions for production: +## Topology -- **Validate schedule inputs**: Clamp `delayMs` and `timestamp` from clients. Reject negative delays, timestamps in the past, and absurdly far-future deadlines, and bound message or payload sizes. -- **Never schedule client-chosen actions**: Expose specific actions like `scheduleReminder` that internally arm a fixed callback. Do not pass a client-supplied action name or unchecked args into `c.schedule`. -- **Authenticate and scope keys**: Add connection [authentication](/docs/actors/authentication) and use per-user actor keys instead of one global key, so users cannot read or cancel each other's schedules. -- **Prune completed entries**: The example's `reminders` array grows without bound. Remove or archive completed entries so state stays small. -- **Use stable IDs**: Generate entry IDs with `crypto.randomUUID()` rather than timestamp-plus-random strings. +Use a singleton actor key for one global job, such as `jobs["daily-report"]`. Use an actor per user or resource for isolated reminders, trials, billing periods, or other per-entity schedules. diff --git a/website/src/content/docs/actors/actor-runtime-socket.mdx b/website/src/content/docs/actors/actor-runtime-socket.mdx index c076e6b89c..5fe657cfb8 100644 --- a/website/src/content/docs/actors/actor-runtime-socket.mdx +++ b/website/src/content/docs/actors/actor-runtime-socket.mdx @@ -1,51 +1,63 @@ --- title: "Actor Runtime Socket" -description: "Connect trusted local tools to a running Rivet Actor over an experimental actor-local protocol." +description: "Access an actor's SQLite database from another local process." skill: true --- -> **Experimental:** The Actor Runtime Socket protocol and API may change between releases. It is available only in the Unix native runtime and must be enabled per actor. +The Actor Runtime Socket is an advanced Beta API for integrating Rivet Actors with external software without sacrificing performance. It currently exposes SQLite over Unix domain sockets in the native runtime. -The **Actor Runtime Socket** is a generic, actor-local protocol for trusted tools running in the same application environment as a Rivet Actor. SQLite is its first capability; Unix domain sockets are its current transport. +For example, an agentOS agent can use the socket to work with its actor's SQLite database. Software orchestrated by an actor, such as another process or container, can use it to access the actor's runtime resources. Like the Chrome DevTools Protocol exposes a browser to external tools, the Actor Runtime Socket exposes selected actor capabilities to colocated software. -Each running actor generation lazily creates its own socket. The socket is removed when that generation sleeps, stops, or is destroyed, so clients must request the current path again after the actor wakes or restarts. +## Quickstart -## Enable the socket +Enable the socket, call `c.actorRuntimeSocket()` to get its path, and pass that path to your process: -Enable the feature under the actor's `options`, then provision it from the actor context: + -```ts @nocheck +```ts actor.ts +import { spawn } from "node:child_process"; import { actor } from "rivetkit"; import { db } from "rivetkit/db"; export const example = actor({ - options: { - enableActorRuntimeSocket: true, - }, - db: db({ - onMigrate: async (db) => { - await db.execute("CREATE TABLE IF NOT EXISTS items (value TEXT)"); - }, - }), - run: async (c) => { - const { path } = await c.actorRuntimeSocket(); - // Give `path` only to trusted, application-local tooling. - }, + options: { enableActorRuntimeSocket: true }, + db: db({ + onMigrate: (db) => + db.execute("CREATE TABLE IF NOT EXISTS items (value TEXT)"), + }), + actions: { + startWorker: async (c) => { + // Get the Unix socket path external processes use + // to communicate with the actor. + // (Example: /tmp/rivet-actor-runtime.abc/def.sock) + const { path } = await c.actorRuntimeSocket(); + + // Spawn a process that can access the actor's + // SQLite database over the socket. + spawn(process.execPath, ["worker.js"], { + env: { ...process.env, ACTOR_RUNTIME_SOCKET_PATH: path }, + }); + }, + }, }); ``` -Provisioning fails unless the actor has an enabled LocalNative SQLite database. The per-process socket directory uses mode `0700`, and each actor socket uses mode `0600`. +```ts worker.ts +import { connectRuntimeSocket } from "./generated/runtime-socket-client"; -## Protocol +const db = await connectRuntimeSocket(process.env.ACTOR_RUNTIME_SOCKET_PATH!); +const rows = await db.query("SELECT value FROM items", []); +console.log(rows); +``` -Unix `SOCK_STREAM` provides a reliable local byte stream but does not preserve message boundaries. Every frame is therefore a four-byte big-endian payload length followed by a versioned BARE (`vbare`) payload. The embedded protocol version is a two-byte little-endian prefix. Clients begin with `ClientHello`; the server returns `HelloOk` with the maximum frame size or rejects an unsupported version. + -The current protocol supports: +The path is valid only for the current actor generation, so get it again after the actor restarts or wakes. Only share it with trusted local processes. + +## Protocol -- multi-statement SQLite scripts with `SqliteExec`; -- single-statement parameterized queries with `SqliteQuery`; -- transactions spanning requests with `SqliteBegin`, `SqliteCommit`, and `SqliteRollback`. +The [protocol schema](https://github.com/rivet-dev/rivet/blob/main/engine/sdks/schemas/actor-runtime-socket-protocol/v1.bare) defines the handshake, SQLite requests, values, responses, and errors. Generate codecs using vbare's [TypeScript](https://github.com/rivet-dev/vbare/tree/main/typescript) or [Rust](https://github.com/rivet-dev/vbare/tree/main/rust) tooling, then add a small Unix socket wrapper for its length-prefixed frames. `connectRuntimeSocket` above represents that application-specific wrapper. -A transaction is identified by a client-chosen `leaseKey` scoped to one connection. Other actor SQL and other socket transactions wait in FIFO order while it runs. Transactions default to a 60-second safety timeout; `SqliteBegin.timeoutMs` may override it. If the deadline fires, RivetKit rolls the transaction back and later requests for that key receive `LeaseExpired`. +## SQLite transaction interleaving -The socket is an application-local integration boundary, not a network authentication boundary. It intentionally has no bearer token. Clients must still honor framing and response limits, and should reconnect using a newly provisioned path after actor generation changes. +Separate socket requests can interleave with SQLite work from the main actor. For multi-request atomic work, begin a transaction with a client-generated `leaseKey`, include that key with each query, and commit or roll back with the same key. Other actor and socket SQL waits until that transaction finishes. diff --git a/website/src/content/docs/actors/connections.mdx b/website/src/content/docs/actors/connections.mdx index 6a8be024d1..4a9832bbe4 100644 --- a/website/src/content/docs/actors/connections.mdx +++ b/website/src/content/docs/actors/connections.mdx @@ -4,7 +4,7 @@ description: "Connections represent client connections to your actor. They provi skill: true --- -For documentation on connecting to actors from clients, see the [Clients documentation](/docs/clients). For worked presence and chat patterns, see the cookbook: [Live Cursors and Presence](/cookbook/live-cursors/) and [Chat Room](/cookbook/chat-room/). +For documentation on connecting to actors from clients, see the [Clients documentation](/docs/clients). ## Parameters diff --git a/website/src/content/docs/actors/crash-course.mdx b/website/src/content/docs/actors/crash-course.mdx index eae84e0b56..24d373e182 100644 --- a/website/src/content/docs/actors/crash-course.mdx +++ b/website/src/content/docs/actors/crash-course.mdx @@ -146,9 +146,9 @@ Actors can call other actors using `c.client()`. [Documentation](/docs/actors/communicating-between-actors) -### Scheduling +### Schedule & Cron -Schedule actions to run after a delay or at a specific time. Schedules persist across restarts, upgrades, and crashes. +Run one-shot actions after a delay or at a specific time. Use named cron jobs for calendar schedules and fixed intervals. Both persist across sleep, restarts, upgrades, and crashes. diff --git a/website/src/content/docs/actors/events.mdx b/website/src/content/docs/actors/events.mdx index 968eec3739..3149e01716 100644 --- a/website/src/content/docs/actors/events.mdx +++ b/website/src/content/docs/actors/events.mdx @@ -6,8 +6,6 @@ skill: true Events can be sent to clients connected using `.connect()`. They have no effect on [low-level WebSocket connections](/docs/actors/websocket-handler). -For worked realtime patterns, see the cookbook: [Live Cursors and Presence](/cookbook/live-cursors/) and [Chat Room](/cookbook/chat-room/). - ## Publishing Events from Actors ### Broadcasting to All Clients diff --git a/website/src/content/docs/actors/limits.mdx b/website/src/content/docs/actors/limits.mdx index 9ef26a6fc2..b3906a6c45 100644 --- a/website/src/content/docs/actors/limits.mdx +++ b/website/src/content/docs/actors/limits.mdx @@ -96,7 +96,7 @@ Avoid `VACUUM` inside actor databases. It rewrites the database in one transacti ### Actor Runtime Socket -The experimental [Actor Runtime Socket](/docs/actors/actor-runtime-socket) negotiates its active frame limit during the handshake. +The Beta [Actor Runtime Socket](/docs/actors/actor-runtime-socket) negotiates its active frame limit during the handshake. | Name | Soft Limit | Hard Limit | Description | |------|------------|------------|-------------| diff --git a/website/src/content/docs/actors/queues.mdx b/website/src/content/docs/actors/queues.mdx index 511ad20cb8..bc582e28e5 100644 --- a/website/src/content/docs/actors/queues.mdx +++ b/website/src/content/docs/actors/queues.mdx @@ -14,8 +14,6 @@ skill: true Queues are commonly referred to as "mailboxes" in other actor frameworks. -For a worked queue-driven pattern, see the cookbook: [AI Agent](/cookbook/ai-agent/). - ## What are queues good for? - Great for any task that changes actor state. diff --git a/website/src/content/docs/actors/schedule.mdx b/website/src/content/docs/actors/schedule.mdx index aaafb7071c..4ad318cf78 100644 --- a/website/src/content/docs/actors/schedule.mdx +++ b/website/src/content/docs/actors/schedule.mdx @@ -1,39 +1,186 @@ --- -title: "Actor Scheduling" -description: "Schedule actor actions in the future with persistent timers that survive restarts and upgrades." +title: "Schedule & Cron" +description: "Run durable one-shot and recurring actor actions on a schedule." skill: true --- -Scheduling is used to trigger events in the future. The actor scheduler is like `setTimeout`, except the timeout will persist even if the actor restarts, upgrades, or crashes. +Rivet Actor schedules invoke actions on the same actor and survive sleep, restarts, upgrades, and crashes. Use `schedule.after` or `schedule.at` for one-time work, `cron.set` for calendar-based jobs, and `cron.every` for frequent fixed-interval jobs. -For a pattern guide to durable recurring jobs, see the cookbook: [Cron Jobs and Scheduled Tasks](/cookbook/cron-jobs/). +## Quick start -## Use Cases + -Scheduling is helpful for long-running timeouts like month-long billing periods or account trials. +```ts After +const reminders = actor({ + onCreate: async (c) => { + await c.schedule.after(30_000, "sendReminder", "reminder-123"); + }, + actions: { + sendReminder: (_c, reminderId: string) => { + console.log("Sending reminder", reminderId); + }, + }, +}); +``` -## Scheduling +```ts Cron +const reports = actor({ + onCreate: async (c) => { + await c.cron.set({ + name: "daily-report", + expression: "0 9 * * *", + action: "runReport", + args: ["sales"], // Optional. + timezone: "America/Los_Angeles", // Optional; defaults to UTC. + maxHistory: 100, // Optional; defaults to 100. Set to 0 to disable. + }); + }, + actions: { + runReport: (_c, report: string) => { + console.log("Running report", report); + }, + }, +}); +``` -### `c.schedule.after(duration, actionName, ...args)` +```ts Every +const cache = actor({ + onCreate: async (c) => { + await c.cron.every({ + name: "refresh-cache", + intervalMs: 60_000, // Minimum 5 seconds. + action: "refreshCache", + args: ["products"], // Optional. + maxHistory: 100, // Optional; defaults to 100. Set to 0 to disable. + }); + }, + actions: { + refreshCache: (_c, cache: string) => { + console.log("Refreshing cache", cache); + }, + }, +}); +``` -Schedules a function to be executed after a specified duration. This function persists across actor restarts, upgrades, or crashes. + -Parameters: +## One-shot schedules -- `duration` (number): The delay in milliseconds. -- `actionName` (string): The name of the action to be executed. -- `...args` (unknown[]): Additional arguments to pass to the function. +### Run after a delay -### `c.schedule.at(timestamp, actionName, ...args)` +`schedule.after(duration, action, ...args)` runs once after `duration` milliseconds and returns its generated schedule ID. -Schedules a function to be executed at a specific timestamp. This function persists across actor restarts, upgrades, or crashes. +```ts +const id = await c.schedule.after(5_000, "sendReminder", reminderId); +``` -Parameters: +### Run at a specific time -- `timestamp` (number): The exact time in milliseconds since the Unix epoch when the function should be executed. -- `actionName` (string): The name of the action to be executed. -- `...args` (unknown[]): Additional arguments to pass to the function. +`schedule.at(timestamp, action, ...args)` runs once at a Unix timestamp in milliseconds. -## Full Example +```ts +const id = await c.schedule.at(Date.parse("2026-08-01T09:00:00Z"), "openSale", saleId); +``` - +### Inspect and cancel schedules + +```ts +const schedule = await c.schedule.get(id); +const pending = await c.schedule.list(); +const cancelled = await c.schedule.cancel(id); +``` + +## Recurring jobs + +### Run on a cron schedule + +`cron.set` uses standard five-field cron expressions. Names are unique per actor, and reusing a name updates the existing job. The timezone defaults to `UTC`. + +```ts +await c.cron.set({ + name: "daily-report", + expression: "0 9 * * *", + action: "runReport", + args: ["sales"], // Optional. + timezone: "America/Los_Angeles", // Optional; defaults to UTC. + maxHistory: 100, // Optional; defaults to 100. Set to 0 to disable. +}); +``` + +### Run at a fixed interval + +`cron.every` keeps its cadence anchored to scheduled deadlines, so action runtime does not introduce drift. Names are unique per actor, and reusing a name updates the existing job. The minimum interval is 5 seconds. + +```ts +await c.cron.every({ + name: "presence-sweep", + intervalMs: 15_000, // Minimum 5 seconds. + action: "sweepPresence", + args: [], // Optional. + maxHistory: 25, // Optional; defaults to 100. Set to 0 to disable. +}); +``` + +### Update and delete jobs + +Calling `set` or `every` with an existing name updates that job; changing its cadence calculates a new next run, while changing only its action, arguments, or history settings preserves the existing cadence. + +```ts +const job = await c.cron.get("daily-report"); +const jobs = await c.cron.list(); +const deleted = await c.cron.delete("daily-report"); +``` + +### View run history + +```ts +const recent = await c.cron.history("daily-report", { + limit: 20, // Optional. +}); +``` + +Each entry reports `running`, `ok`, `error`, or `skipped`. Jobs retain 100 entries by default. Set `maxHistory: 0` to disable and clear history, or choose up to 1,000 entries. An actor retains at most 10,000 history entries across all jobs. + +## Run information + +When a schedule invokes an action, it appends `ScheduledFireInfo` after the configured `args`. + +```ts +import type { ScheduledFireInfo } from "rivetkit"; + +const reports = actor({ + onCreate: async (c) => { + await c.cron.set({ + name: "daily-report", + expression: "0 9 * * *", + action: "runReport", + args: ["sales"], // Optional. + }); + }, + actions: { + runReport: ( + _c, + report: string, + fire: ScheduledFireInfo, + ) => { + console.log( + report, + fire.kind, + fire.id, + fire.name, + fire.scheduledAt, + fire.firedAt, + ); + }, + }, +}); +``` + +## Execution behavior + +- **Durability**: [Actors sleep when not in use](/docs/actors/lifecycle#sleeping), but schedules are durable, wake the actor on demand, and survive crashes. +- **Overdue one-shots**: Overdue one-shot schedules run when the actor resumes. +- **Failures**: Failed runs are not retried immediately. A failed recurring run continues at the next normal cadence; a failed one-shot is complete after its attempted invocation. Cron failures are available in [run history](#view-run-history), so you can build a custom retry mechanism. +- **Idempotency**: Scheduled actions should be idempotent so manually retrying a failed or interrupted run is safe. +- **Overlaps**: Overlapping recurring runs are skipped. +- **Missing actions**: Recurring jobs are deleted when their action no longer exists. diff --git a/website/src/content/docs/actors/sqlite-drizzle.mdx b/website/src/content/docs/actors/sqlite-drizzle.mdx index 9b4027e613..e5cc4cef6f 100644 --- a/website/src/content/docs/actors/sqlite-drizzle.mdx +++ b/website/src/content/docs/actors/sqlite-drizzle.mdx @@ -6,7 +6,7 @@ skill: true Use Drizzle when you want typed schema, typed queries, and generated migrations on top of actor-local SQLite. -For a high-level overview of where to store actor data, see [State & Storage](/docs/actors/state). For a worked multi-tenant pattern, see the cookbook: [Database per Tenant](/cookbook/per-tenant-database/). +For a high-level overview of where to store actor data, see [State & Storage](/docs/actors/state). ## What is Drizzle good for? diff --git a/website/src/content/docs/actors/sqlite.mdx b/website/src/content/docs/actors/sqlite.mdx index 1807954aab..36e1943d9b 100644 --- a/website/src/content/docs/actors/sqlite.mdx +++ b/website/src/content/docs/actors/sqlite.mdx @@ -4,7 +4,7 @@ description: "Use embedded SQLite in Rivet Actors with raw SQL queries." skill: true --- -For a high-level overview of where to store actor data, including when to use `c.state` versus SQLite, see [State & Storage](/docs/actors/state). For a worked multi-tenant pattern, see the cookbook: [Database per Tenant](/cookbook/per-tenant-database/). +For a high-level overview of where to store actor data, including when to use `c.state` versus SQLite, see [State & Storage](/docs/actors/state). ## What is SQLite? diff --git a/website/src/content/docs/actors/testing.mdx b/website/src/content/docs/actors/testing.mdx index f3302ec890..eba793bfee 100644 --- a/website/src/content/docs/actors/testing.mdx +++ b/website/src/content/docs/actors/testing.mdx @@ -40,13 +40,13 @@ Rivet's schedule functionality can be tested by scheduling work and waiting for -Use a short real-time delay to wait for scheduled work to run. `setupTest` does not install fake timers, so if you want to use `vi.useFakeTimers()` you must enable it yourself and confirm it works with your selected runtime. +Use a short schedule and `expect.poll` the action's observable result. Vitest's fake date and fake JavaScript timers do not advance RivetKit's scheduler, which runs outside the test's JavaScript timer queue. ## Best Practices 1. **Isolate tests**: Each test should run independently, avoiding shared state. 2. **Test edge cases**: Verify how your actor handles invalid inputs, concurrent operations, and error conditions. -3. **Test scheduled operations**: Use short real-time delays to wait for scheduled work to run. +3. **Test scheduled operations**: Poll observable state or output with a bounded timeout instead of sleeping for an exact duration. 4. **Use realistic data**: Test with data that resembles production scenarios. `setupTest` starts the registry and disposes the returned client when the test finishes, so you can focus on writing effective tests for your business logic. diff --git a/website/src/sitemap/mod.ts b/website/src/sitemap/mod.ts index a625088a51..aa2ce9dec2 100644 --- a/website/src/sitemap/mod.ts +++ b/website/src/sitemap/mod.ts @@ -173,7 +173,7 @@ export const sitemap = [ icon: faMailbox, }, { - title: "Schedule", + title: "Schedule & Cron", href: "/docs/actors/schedule", icon: faClock, }, @@ -182,11 +182,6 @@ export const sitemap = [ href: "/docs/actors/sqlite", icon: faSqlite, }, - { - title: "Actor Runtime Socket", - href: "/docs/actors/actor-runtime-socket", - badge: "Experimental", - }, // { // title: "Persistence", // collapsible: true, @@ -347,6 +342,11 @@ export const sitemap = [ title: "Custom Inspector Tabs", href: "/docs/actors/inspector-tabs", }, + { + title: "Actor Runtime Socket", + href: "/docs/actors/actor-runtime-socket", + badge: "Beta", + }, { title: "Limits", href: "/docs/actors/limits", From 1b43c93f8752bafc1cbbf5fc70f37eb2661636fd Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 10:38:20 -0700 Subject: [PATCH 2/8] fix(rivetkit): configure inspector protocol versions --- .../actors/actor-inspector-context.tsx | 4 ++++ .../src/registry/inspector_ws.rs | 5 +++++ .../tests/driver/actor-inspector.test.ts | 20 +++++++++++-------- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/actors/actor-inspector-context.tsx b/frontend/src/components/actors/actor-inspector-context.tsx index 5813043ab5..1563e54d68 100644 --- a/frontend/src/components/actors/actor-inspector-context.tsx +++ b/frontend/src/components/actors/actor-inspector-context.tsx @@ -196,6 +196,10 @@ const MIN_RIVETKIT_VERSION_WORKFLOW_REPLAY = "2.1.6"; // with runtimes new enough to send it. const MIN_RIVETKIT_VERSION_TABCONFIG_INIT = "2.3.3"; const MIN_RIVETKIT_VERSION_SCHEDULES = "2.3.4"; +// Before 2.3.4, inspector WebSockets did not require a protocol version on the +// URL and instead embedded one in every message. Only use connection-level +// negotiation with runtimes that implement it; older runtimes need the legacy +// URL and embedded framing. const MIN_RIVETKIT_VERSION_INSPECTOR_NEGOTIATION = "2.3.4"; const INSPECTOR_ERROR_EVENTS_DROPPED = "inspector.events_dropped"; diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs index 32d6456e86..c8df1d4e16 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/inspector_ws.rs @@ -5,6 +5,11 @@ use super::*; use std::future::Future; use tracing::Instrument; +/// Inspector WebSockets originally did not require `protocol_version` on the +/// connection URL. Those clients embed a version header in every message, and +/// v5 was current when connection-level negotiation was introduced. Keep a +/// missing query parameter on that v5 framing so older deployed clients remain +/// compatible; current clients must send `protocol_version` once on the URL. const LEGACY_INSPECTOR_VERSION: u16 = 5; #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/rivetkit-typescript/packages/rivetkit/tests/driver/actor-inspector.test.ts b/rivetkit-typescript/packages/rivetkit/tests/driver/actor-inspector.test.ts index 52d920f9b1..a5f5b27e21 100644 --- a/rivetkit-typescript/packages/rivetkit/tests/driver/actor-inspector.test.ts +++ b/rivetkit-typescript/packages/rivetkit/tests/driver/actor-inspector.test.ts @@ -99,7 +99,11 @@ function buildInspectorUrl( } function buildInspectorWebSocketUrl(gatewayUrl: string): string { - const url = new URL(buildInspectorUrl(gatewayUrl, "/inspector/connect")); + const url = new URL( + buildInspectorUrl(gatewayUrl, "/inspector/connect", { + protocol_version: String(INSPECTOR_PROTOCOL_VERSION), + }), + ); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; return url.toString(); } @@ -117,9 +121,7 @@ async function toBinaryPayload(data: Blob | ArrayBuffer | Buffer | string) { return new Uint8Array(data); } -type InspectorMessage = ReturnType< - typeof TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion ->; +type InspectorMessage = ReturnType; async function waitForInspectorMessage( ws: WebSocket, @@ -148,8 +150,10 @@ async function waitForInspectorMessage( const payload = await toBinaryPayload( event.data as Blob | ArrayBuffer | Buffer | string, ); - const decoded = - TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(payload); + const decoded = TO_CLIENT_VERSIONED.deserialize( + payload, + INSPECTOR_PROTOCOL_VERSION, + ); if (predicate && !predicate(decoded)) { return; } @@ -312,7 +316,7 @@ describeDriverMatrix("Actor Inspector", (driverTestConfig) => { await waitForInspectorMessageWithTag(ws, "Init"); ws.send( - TO_SERVER_VERSIONED.serializeWithEmbeddedVersion( + TO_SERVER_VERSIONED.serialize( { body: { tag: "PatchStateRequest", @@ -335,7 +339,7 @@ describeDriverMatrix("Actor Inspector", (driverTestConfig) => { ).toEqual({ count: 42 }); ws.send( - TO_SERVER_VERSIONED.serializeWithEmbeddedVersion( + TO_SERVER_VERSIONED.serialize( { body: { tag: "StateRequest", From 463c4b35ba016d97273eead6c87852c450dffaec Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 10:51:54 -0700 Subject: [PATCH 3/8] docs(rivetkit): use scheduling code snippets --- website/src/content/docs/actors/schedule.mdx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/website/src/content/docs/actors/schedule.mdx b/website/src/content/docs/actors/schedule.mdx index 4ad318cf78..0bbef5bf3c 100644 --- a/website/src/content/docs/actors/schedule.mdx +++ b/website/src/content/docs/actors/schedule.mdx @@ -4,7 +4,7 @@ description: "Run durable one-shot and recurring actor actions on a schedule." skill: true --- -Rivet Actor schedules invoke actions on the same actor and survive sleep, restarts, upgrades, and crashes. Use `schedule.after` or `schedule.at` for one-time work, `cron.set` for calendar-based jobs, and `cron.every` for frequent fixed-interval jobs. +Rivet Actor schedules invoke actions on the same actor and survive sleep, restarts, upgrades, and crashes. Use one-shot schedules for delayed work, cron jobs for calendar-based work, and fixed intervals for frequent jobs. ## Quick start @@ -68,7 +68,7 @@ const cache = actor({ ### Run after a delay -`schedule.after(duration, action, ...args)` runs once after `duration` milliseconds and returns its generated schedule ID. +Runs once after the given delay in milliseconds and returns the generated schedule ID. ```ts const id = await c.schedule.after(5_000, "sendReminder", reminderId); @@ -76,7 +76,7 @@ const id = await c.schedule.after(5_000, "sendReminder", reminderId); ### Run at a specific time -`schedule.at(timestamp, action, ...args)` runs once at a Unix timestamp in milliseconds. +Runs once at a Unix timestamp in milliseconds. ```ts const id = await c.schedule.at(Date.parse("2026-08-01T09:00:00Z"), "openSale", saleId); @@ -94,7 +94,7 @@ const cancelled = await c.schedule.cancel(id); ### Run on a cron schedule -`cron.set` uses standard five-field cron expressions. Names are unique per actor, and reusing a name updates the existing job. The timezone defaults to `UTC`. +Cron jobs use standard five-field expressions. Names are unique per actor, and reusing a name updates the existing job. The timezone defaults to UTC. ```ts await c.cron.set({ @@ -109,7 +109,7 @@ await c.cron.set({ ### Run at a fixed interval -`cron.every` keeps its cadence anchored to scheduled deadlines, so action runtime does not introduce drift. Names are unique per actor, and reusing a name updates the existing job. The minimum interval is 5 seconds. +Fixed-interval jobs keep their cadence anchored to scheduled deadlines, so action runtime does not introduce drift. Names are unique per actor, and reusing a name updates the existing job. The minimum interval is 5 seconds. ```ts await c.cron.every({ @@ -123,7 +123,7 @@ await c.cron.every({ ### Update and delete jobs -Calling `set` or `every` with an existing name updates that job; changing its cadence calculates a new next run, while changing only its action, arguments, or history settings preserves the existing cadence. +Submitting a recurring job with an existing name updates it. Changing its cadence calculates a new next run, while changing only its action, arguments, or history settings preserves the existing cadence. ```ts const job = await c.cron.get("daily-report"); @@ -139,11 +139,11 @@ const recent = await c.cron.history("daily-report", { }); ``` -Each entry reports `running`, `ok`, `error`, or `skipped`. Jobs retain 100 entries by default. Set `maxHistory: 0` to disable and clear history, or choose up to 1,000 entries. An actor retains at most 10,000 history entries across all jobs. +Each entry reports whether the run is running, succeeded, failed, or was skipped. Jobs retain 100 entries by default. Set maximum history to zero to disable and clear history, or choose up to 1,000 entries. An actor retains at most 10,000 history entries across all jobs. ## Run information -When a schedule invokes an action, it appends `ScheduledFireInfo` after the configured `args`. +When a schedule invokes an action, it appends a ScheduledFireInfo object after the configured action arguments. ```ts import type { ScheduledFireInfo } from "rivetkit"; From 5112829b0e40d2a08c8a53cf8043acb2d0f4962b Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 10:56:49 -0700 Subject: [PATCH 4/8] docs(rivetkit): remove redundant overdue schedule note --- website/src/content/docs/actors/schedule.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/content/docs/actors/schedule.mdx b/website/src/content/docs/actors/schedule.mdx index 0bbef5bf3c..7e6539a722 100644 --- a/website/src/content/docs/actors/schedule.mdx +++ b/website/src/content/docs/actors/schedule.mdx @@ -179,7 +179,6 @@ const reports = actor({ ## Execution behavior - **Durability**: [Actors sleep when not in use](/docs/actors/lifecycle#sleeping), but schedules are durable, wake the actor on demand, and survive crashes. -- **Overdue one-shots**: Overdue one-shot schedules run when the actor resumes. - **Failures**: Failed runs are not retried immediately. A failed recurring run continues at the next normal cadence; a failed one-shot is complete after its attempted invocation. Cron failures are available in [run history](#view-run-history), so you can build a custom retry mechanism. - **Idempotency**: Scheduled actions should be idempotent so manually retrying a failed or interrupted run is safe. - **Overlaps**: Overlapping recurring runs are skipped. From 320bb21d3c76a1634a51717cbbda0ea44ad241b7 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 14:21:05 -0700 Subject: [PATCH 5/8] fix(rivetkit): clean up cron history efficiently --- .../src/actor/internal_schema.rs | 6 +++- .../rivetkit-core/src/actor/schedule.rs | 36 +++++++++++++------ .../packages/rivetkit-core/src/testing.rs | 4 ++- .../packages/rivetkit-core/tests/context.rs | 4 ++- .../packages/rivetkit-core/tests/schedule.rs | 36 +++++++++++++++++++ 5 files changed, 72 insertions(+), 14 deletions(-) diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs index 53ce54ccb3..dac9e3f8f1 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs @@ -92,7 +92,11 @@ CREATE TABLE _rivet_schedule_history ( "#, r#" CREATE INDEX _rivet_schedule_history_schedule - ON _rivet_schedule_history (schedule_id, fired_at DESC) + ON _rivet_schedule_history (schedule_id, fired_at DESC, id DESC) +"#, + r#" +CREATE INDEX _rivet_schedule_history_fired_at + ON _rivet_schedule_history (fired_at DESC, id DESC) "#, ], &[ diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs index 92852b6be0..170a34ec9f 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs @@ -414,18 +414,32 @@ impl ActorContext { pub async fn cron_delete(&self, name: &str) -> Result { validate_name(name)?; let _mutation = self.0.schedule_mutation_lock.lock().await; - let result = self + let event_id = cron_event_id(name); + let results = self .sql() - .execute( - "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?", - Some(vec![ - BindParam::Text(cron_event_id(name)), - BindParam::Integer(ScheduleKind::At.as_i64()), - ]), - ) + .execute_batch(vec![ + SqliteBatchStatement { + sql: "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND EXISTS (SELECT 1 FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?)".to_owned(), + params: Some(vec![ + BindParam::Text(event_id.clone()), + BindParam::Text(event_id.clone()), + BindParam::Integer(ScheduleKind::At.as_i64()), + ]), + }, + SqliteBatchStatement { + sql: "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?" + .to_owned(), + params: Some(vec![ + BindParam::Text(event_id), + BindParam::Integer(ScheduleKind::At.as_i64()), + ]), + }, + ]) .await - .context("delete recurring schedule")?; - let removed = result.changes > 0; + .context("delete recurring schedule and history")?; + let removed = results + .get(1) + .is_some_and(|event_result| event_result.changes > 0); if removed { self.mark_schedule_dirty(); self.record_schedules_updated(); @@ -1141,7 +1155,7 @@ fn history_prune_statements(event_id: &str, max_history: i64) -> Vec Date: Sun, 19 Jul 2026 15:25:04 -0700 Subject: [PATCH 6/8] perf(rivetkit): verify and optimize internal sqlite queries --- CLAUDE.md | 1 + .../packages/depot-client/tests/inline/vfs.rs | 73 +- .../src/actor_remote_sqlite_task.rs | 13 + .../pegboard-envoy/src/ws_to_tunnel_task.rs | 142 ++++ .../rust/envoy-client/src/connection/mod.rs | 1 + engine/sdks/rust/envoy-client/src/envoy.rs | 6 +- engine/sdks/rust/envoy-client/src/handle.rs | 14 + engine/sdks/rust/envoy-client/src/sqlite.rs | 66 ++ .../sdks/rust/envoy-client/src/stringify.rs | 15 + .../sdks/rust/envoy-protocol/schemas/v6.bare | 675 +++++++++++++++ engine/sdks/rust/envoy-protocol/src/lib.rs | 2 +- .../rust/envoy-protocol/src/versioned/mod.rs | 166 +++- .../envoy-protocol/src/versioned/v5_to_v6.rs | 770 +++++++++++++++++ .../envoy-protocol/src/versioned/v6_to_v5.rs | 789 ++++++++++++++++++ .../envoy-protocol/tests/remote_sql_compat.rs | 98 ++- .../tests/stateless_sqlite_v3.rs | 2 +- engine/sdks/schemas/envoy-protocol/v6.bare | 675 +++++++++++++++ .../typescript/envoy-protocol/src/index.ts | 302 +++++-- .../rivetkit-core/src/actor/context.rs | 2 + .../src/actor/internal_schema.rs | 13 +- .../src/actor/internal_storage.rs | 138 +-- .../packages/rivetkit-core/src/actor/mod.rs | 3 + .../rivetkit-core/src/actor/schedule.rs | 179 ++-- .../rivetkit-core/src/actor/sqlite/mod.rs | 85 +- .../packages/rivetkit-core/src/testing.rs | 59 +- .../packages/rivetkit-core/tests/context.rs | 109 ++- .../tests/migrate_kv_to_sqlite.rs | 107 ++- .../packages/rivetkit-core/tests/schedule.rs | 31 +- .../rivetkit-core/tests/sql_efficiency.rs | 512 ++++++++++++ .../packages/rivetkit-core/tests/sqlite.rs | 73 +- .../packages/rivetkit-core/tests/task.rs | 58 +- 31 files changed, 4891 insertions(+), 288 deletions(-) create mode 100644 engine/sdks/rust/envoy-protocol/schemas/v6.bare create mode 100644 engine/sdks/rust/envoy-protocol/src/versioned/v5_to_v6.rs create mode 100644 engine/sdks/rust/envoy-protocol/src/versioned/v6_to_v5.rs create mode 100644 engine/sdks/schemas/envoy-protocol/v6.bare create mode 100644 rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs diff --git a/CLAUDE.md b/CLAUDE.md index de79a96d70..246f1784a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,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 stored-token initialization path. +- Every new or modified internal SQLite `SELECT`, `UPDATE`, or `DELETE` in `rivetkit-core` must add or update a query-efficiency test using the production SQL. Use representative cardinality to reject full scans, temporary sorts, and automatic indexes where indexed access is expected; allowlist intentional scans in the catalog with a concrete reason and production bound. Simple inserts and schema DDL need correctness or migration coverage but no plan assertion. Assert semantic plan properties rather than exact `EXPLAIN QUERY PLAN` text, and run `cargo test -p rivetkit-core sql_efficiency --lib` after internal schema, index, or query changes. This policy excludes user-provided SQL. - 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. diff --git a/engine/packages/depot-client/tests/inline/vfs.rs b/engine/packages/depot-client/tests/inline/vfs.rs index 19f8bd2058..f275c06272 100644 --- a/engine/packages/depot-client/tests/inline/vfs.rs +++ b/engine/packages/depot-client/tests/inline/vfs.rs @@ -58,7 +58,6 @@ fn vfs_config_wires_optimization_flags() { vfs_page_cache_capacity_pages: DEFAULT_VFS_PAGE_CACHE_CAPACITY_PAGES / 2, vfs_protected_cache_pages: DEFAULT_VFS_PROTECTED_CACHE_PAGES / 2, vfs_staging_cache_ttl_ms: DEFAULT_VFS_STAGING_CACHE_TTL_MS / 2, - vfs_retain_read_cache: true, pager_cache_size_kib: DEFAULT_PAGER_CACHE_SIZE_KIB, }; @@ -5679,6 +5678,7 @@ fn warm_pidx_stale_read_then_rmw_commit_via_natural_reopen() { request.now_ms, CommitOptions { expected_head_txid: request.expected_head_txid, + disable_size_cap: false, }, ) .await @@ -6175,6 +6175,77 @@ fn vfs_records_commit_phase_durations() { assert!(metrics.request_build_ns + metrics.transport_ns + metrics.state_update_ns > 0); } +#[test] +fn partial_status_index_reduces_storage_and_cold_page_fetches() { + let runtime = direct_runtime(); + let harness = DirectEngineHarness::new(); + let engine = runtime.block_on(harness.open_engine()); + let relaxed = std::sync::atomic::Ordering::Relaxed; + + let measure = |actor_id: &str, index_sql: &str| { + let db = harness.open_db_on_engine( + &runtime, + engine.clone(), + actor_id, + VfsConfig::default(), + ); + sqlite_exec( + db.as_ptr(), + "CREATE TABLE history (id INTEGER PRIMARY KEY, result INTEGER NOT NULL, payload BLOB NOT NULL);", + ) + .expect("create history table"); + sqlite_exec(db.as_ptr(), index_sql).expect("create history index"); + sqlite_exec( + db.as_ptr(), + "WITH RECURSIVE seq(id) AS (SELECT 1 UNION ALL SELECT id + 1 FROM seq WHERE id < 4000) INSERT INTO history (id, result, payload) SELECT id, CASE WHEN id = 1 THEN 0 ELSE 1 END, randomblob(256) FROM seq;", + ) + .expect("populate history table"); + let page_count = sqlite_query_i64(db.as_ptr(), "PRAGMA page_count;") + .expect("read database page count"); + drop(db); + + let reopened = harness.open_db_on_engine( + &runtime, + engine.clone(), + actor_id, + VfsConfig::default(), + ); + let ctx = direct_vfs_ctx(&reopened); + ctx.resolve_pages_fetches.store(0, relaxed); + ctx.pages_fetched_total.store(0, relaxed); + sqlite_exec( + reopened.as_ptr(), + "UPDATE history SET payload = randomblob(256) WHERE result = 0;", + ) + .expect("update running history row"); + ( + page_count, + ctx.resolve_pages_fetches.load(relaxed), + ctx.pages_fetched_total.load(relaxed), + ) + }; + + let full = measure( + &next_test_name("sqlite-full-status-index"), + "CREATE INDEX history_result ON history (result);", + ); + let partial = measure( + &next_test_name("sqlite-partial-status-index"), + "CREATE INDEX history_running ON history (result) WHERE result = 0;", + ); + + assert!(partial.0 < full.0, "partial index should persist fewer pages"); + assert!(partial.1 > 0, "cold update should fetch pages from storage"); + assert!( + partial.1 <= full.1, + "partial index should not add cold storage round trips: partial={partial:?}, full={full:?}" + ); + assert!( + partial.2 <= full.2, + "partial index should not fetch more cold pages: partial={partial:?}, full={full:?}" + ); +} + #[test] fn profile_large_tx_insert_5mb() { // 5MB = 1280 rows x 4KB blobs in one transaction diff --git a/engine/packages/pegboard-envoy/src/actor_remote_sqlite_task.rs b/engine/packages/pegboard-envoy/src/actor_remote_sqlite_task.rs index 2079510672..533e40a46e 100644 --- a/engine/packages/pegboard-envoy/src/actor_remote_sqlite_task.rs +++ b/engine/packages/pegboard-envoy/src/actor_remote_sqlite_task.rs @@ -28,6 +28,7 @@ impl Key { pub(super) enum Message { Exec(protocol::ToRivetSqliteExecRequest), Execute(protocol::ToRivetSqliteExecuteRequest), + ExecuteBatch(protocol::ToRivetSqliteExecuteBatchRequest), } pub(super) async fn task( @@ -52,6 +53,18 @@ pub(super) async fn task( ws_to_tunnel_task::send_sqlite_execute_response(&conn, req.request_id, response) .await?; } + Ok(Some(Message::ExecuteBatch(req))) => { + let response = ws_to_tunnel_task::handle_remote_sqlite_execute_batch_response( + &ctx, &conn, req.data, + ) + .await; + ws_to_tunnel_task::send_sqlite_execute_batch_response( + &conn, + req.request_id, + response, + ) + .await?; + } Ok(None) | Err(_) => return Ok(TaskExit::RemoteSqlite(key)), } } 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 1e3bedc558..0e55d354c7 100644 --- a/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs +++ b/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs @@ -44,6 +44,7 @@ use crate::{ }; const MAX_REMOTE_SQL_BIND_BYTES: usize = 128 * 1024; +const MAX_REMOTE_SQL_BATCH_STATEMENTS: usize = 256; /// Wall-clock threshold above which a single handle_message invocation is logged as a head-of-line /// blocking risk. The ws_to_tunnel_task loop is strictly serial per envoy, so any handler that @@ -609,6 +610,7 @@ fn message_kind_label(msg: &protocol::ToRivet) -> &'static str { protocol::ToRivet::ToRivetSqliteCommitRequest(_) => "sqlite_commit", protocol::ToRivet::ToRivetSqliteExecRequest(_) => "sqlite_exec", protocol::ToRivet::ToRivetSqliteExecuteRequest(_) => "sqlite_execute", + protocol::ToRivet::ToRivetSqliteExecuteBatchRequest(_) => "sqlite_execute_batch", protocol::ToRivet::ToRivetTunnelMessage(_) => "tunnel_message", protocol::ToRivet::ToRivetMetadata(_) => "metadata", protocol::ToRivet::ToRivetEvents(_) => "events", @@ -720,6 +722,14 @@ async fn dispatch_message( task_manager .enqueue_remote_sqlite(key, actor_remote_sqlite_task::Message::Execute(req))?; } + protocol::ToRivet::ToRivetSqliteExecuteBatchRequest(req) => { + let key = + actor_remote_sqlite_task::Key::new(req.data.actor_id.clone(), req.data.generation); + task_manager.enqueue_remote_sqlite( + key, + actor_remote_sqlite_task::Message::ExecuteBatch(req), + )?; + } protocol::ToRivet::ToRivetTunnelMessage(tunnel_msg) => { let inner_data_len = tunnel_message_inner_data_len(&tunnel_msg.message_kind); if inner_data_len > ctx.config().pegboard().envoy_max_response_payload_size() { @@ -1140,6 +1150,47 @@ pub(super) async fn handle_remote_sqlite_execute_response( response } +pub(super) async fn handle_remote_sqlite_execute_batch_response( + ctx: &StandaloneCtx, + conn: &Conn, + request: protocol::SqliteExecuteBatchRequest, +) -> protocol::SqliteExecuteBatchResponse { + let start = Instant::now(); + let actor_id = request.actor_id.clone(); + let request_bytes = request + .statements + .iter() + .map(|statement| statement.sql.len() + bind_params_bytes(statement.params.as_ref())) + .sum(); + let response = match handle_remote_sqlite_execute_batch(ctx, conn, request).await { + Ok(results) => protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk( + protocol::SqliteExecuteBatchOk { + results: results.into_iter().map(protocol_execute_result).collect(), + }, + ), + Err(err) => { + tracing::error!(actor_id = %actor_id, ?err, "remote sqlite execute batch request failed"); + protocol::SqliteExecuteBatchResponse::SqliteErrorResponse(sqlite_error_response(&err)) + } + }; + record_sqlite_request_metrics( + conn, + "execute_batch", + sqlite_execute_batch_response_kind(&response), + start, + ); + record_sqlite_payload_bytes(conn, "execute_batch", "request", request_bytes); + if let protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk(ok) = &response { + record_sqlite_payload_bytes( + conn, + "execute_batch", + "response", + ok.results.iter().map(execute_result_bytes).sum(), + ); + } + response +} + pub(super) async fn ack_commands( ctx: &StandaloneCtx, namespace_id: Id, @@ -1552,6 +1603,15 @@ fn sqlite_execute_response_kind(response: &protocol::SqliteExecuteResponse) -> & } } +fn sqlite_execute_batch_response_kind( + response: &protocol::SqliteExecuteBatchResponse, +) -> &'static str { + match response { + protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk(_) => "ok", + protocol::SqliteExecuteBatchResponse::SqliteErrorResponse(_) => "error", + } +} + fn record_sqlite_request_metrics( conn: &Conn, request_type: &'static str, @@ -1658,6 +1718,73 @@ async fn handle_remote_sqlite_execute( database.execute(request.sql, params).await } +async fn handle_remote_sqlite_execute_batch( + ctx: &StandaloneCtx, + conn: &Conn, + request: protocol::SqliteExecuteBatchRequest, +) -> Result> { + validate_remote_sqlite_actor( + ctx, + conn, + &request.namespace_id, + &request.actor_id, + request.generation, + ) + .await?; + if request.statements.len() > MAX_REMOTE_SQL_BATCH_STATEMENTS { + bail!( + "remote sqlite batch had {} statements, exceeding limit {MAX_REMOTE_SQL_BATCH_STATEMENTS}", + request.statements.len() + ); + } + let bind_bytes = request + .statements + .iter() + .map(|statement| bind_params_bytes(statement.params.as_ref())) + .sum::(); + if bind_bytes > MAX_REMOTE_SQL_BIND_BYTES { + bail!( + "remote sqlite batch bind params had {bind_bytes} bytes, exceeding limit {MAX_REMOTE_SQL_BIND_BYTES}" + ); + } + + let actor_db = actor_db(ctx, conn, request.actor_id.clone()).await?; + let database = remote_sqlite_executor_from_parts( + &conn.remote_sqlite_executors, + actor_db, + &request.actor_id, + request.generation, + ) + .await?; + database.exec("BEGIN".to_owned()).await?; + let mut results = Vec::with_capacity(request.statements.len()); + for statement in request.statements { + let params = statement + .params + .map(|params| params.into_iter().map(bind_param_from_protocol).collect()); + match database.execute(statement.sql, params).await { + Ok(result) => results.push(result), + Err(error) => { + return match database.exec("ROLLBACK".to_owned()).await { + Ok(_) => Err(error.context("execute remote sqlite batch statement")), + Err(rollback_error) => Err(error + .context("execute remote sqlite batch statement") + .context(rollback_error.context("rollback remote sqlite batch"))), + }; + } + } + } + if let Err(error) = database.exec("COMMIT".to_owned()).await { + return match database.exec("ROLLBACK".to_owned()).await { + Ok(_) => Err(error.context("commit remote sqlite batch")), + Err(rollback_error) => Err(error + .context("commit remote sqlite batch") + .context(rollback_error.context("rollback remote sqlite batch after failed commit"))), + }; + } + Ok(results) +} + async fn validate_remote_sqlite_actor( ctx: &StandaloneCtx, conn: &Conn, @@ -2176,6 +2303,21 @@ pub(super) async fn send_sqlite_execute_response( .await } +pub(super) async fn send_sqlite_execute_batch_response( + conn: &Conn, + request_id: u32, + data: protocol::SqliteExecuteBatchResponse, +) -> Result<()> { + send_to_envoy( + conn, + protocol::ToEnvoy::ToEnvoySqliteExecuteBatchResponse( + protocol::ToEnvoySqliteExecuteBatchResponse { request_id, data }, + ), + "sqlite execute batch response", + ) + .await +} + async fn send_to_envoy(conn: &Conn, msg: protocol::ToEnvoy, description: &str) -> Result<()> { let serialized = versioned::ToEnvoy::wrap_latest(msg) .serialize(conn.protocol_version) diff --git a/engine/sdks/rust/envoy-client/src/connection/mod.rs b/engine/sdks/rust/envoy-client/src/connection/mod.rs index 61780e6890..b88c3ec417 100644 --- a/engine/sdks/rust/envoy-client/src/connection/mod.rs +++ b/engine/sdks/rust/envoy-client/src/connection/mod.rs @@ -212,6 +212,7 @@ fn to_rivet_kind(message: &protocol::ToRivet) -> &'static str { protocol::ToRivet::ToRivetSqliteCommitRequest(_) => "sqlite_commit", protocol::ToRivet::ToRivetSqliteExecRequest(_) => "sqlite_exec", protocol::ToRivet::ToRivetSqliteExecuteRequest(_) => "sqlite_execute", + protocol::ToRivet::ToRivetSqliteExecuteBatchRequest(_) => "sqlite_execute_batch", protocol::ToRivet::ToRivetTunnelMessage(_) => "tunnel_message", } } diff --git a/engine/sdks/rust/envoy-client/src/envoy.rs b/engine/sdks/rust/envoy-client/src/envoy.rs index 53e5bab6be..dc91ec9400 100644 --- a/engine/sdks/rust/envoy-client/src/envoy.rs +++ b/engine/sdks/rust/envoy-client/src/envoy.rs @@ -30,7 +30,8 @@ use crate::sqlite::{ SqliteRequestEntry, SqliteResponse, cleanup_old_remote_sqlite_requests, cleanup_old_sqlite_requests, fail_remote_sqlite_requests_with_shutdown, fail_sent_remote_sqlite_requests_with_indeterminate_result, fail_sqlite_requests_with_shutdown, - handle_remote_sqlite_exec_response, handle_remote_sqlite_execute_response, + handle_remote_sqlite_exec_response, handle_remote_sqlite_execute_batch_response, + handle_remote_sqlite_execute_response, handle_remote_sqlite_request, handle_sqlite_commit_response, handle_sqlite_get_pages_response, handle_sqlite_request, process_unsent_remote_sqlite_requests, process_unsent_sqlite_requests, }; @@ -600,6 +601,9 @@ async fn handle_conn_message( protocol::ToEnvoy::ToEnvoySqliteExecuteResponse(response) => { handle_remote_sqlite_execute_response(ctx, response).await; } + protocol::ToEnvoy::ToEnvoySqliteExecuteBatchResponse(response) => { + handle_remote_sqlite_execute_batch_response(ctx, response).await; + } protocol::ToEnvoy::ToEnvoyTunnelMessage(tunnel_msg) => { handle_tunnel_message(ctx, tunnel_msg).await; } diff --git a/engine/sdks/rust/envoy-client/src/handle.rs b/engine/sdks/rust/envoy-client/src/handle.rs index 1ab772c12b..93a8665a5b 100644 --- a/engine/sdks/rust/envoy-client/src/handle.rs +++ b/engine/sdks/rust/envoy-client/src/handle.rs @@ -535,6 +535,20 @@ impl EnvoyHandle { } } + pub async fn remote_sqlite_execute_batch( + &self, + request: protocol::SqliteExecuteBatchRequest, + ) -> anyhow::Result { + match self + .send_remote_sqlite_request(RemoteSqliteRequest::ExecuteBatch(request), None) + .await? + .response + { + RemoteSqliteResponse::ExecuteBatch(response) => Ok(response), + _ => anyhow::bail!("unexpected remote sqlite execute batch response type"), + } + } + /// Executes remote SQLite on one exact WebSocket session and returns the /// session that carried the response. Passing `None` allows an unsent request /// to wait for the next connection; passing `Some` fails before sending if diff --git a/engine/sdks/rust/envoy-client/src/sqlite.rs b/engine/sdks/rust/envoy-client/src/sqlite.rs index 15c357bbf4..dc257457ae 100644 --- a/engine/sdks/rust/envoy-client/src/sqlite.rs +++ b/engine/sdks/rust/envoy-client/src/sqlite.rs @@ -34,12 +34,14 @@ pub enum SqliteResponse { pub enum RemoteSqliteRequest { Exec(protocol::SqliteExecRequest), Execute(protocol::SqliteExecuteRequest), + ExecuteBatch(protocol::SqliteExecuteBatchRequest), } #[derive(Debug)] pub enum RemoteSqliteResponse { Exec(protocol::SqliteExecResponse), Execute(protocol::SqliteExecuteResponse), + ExecuteBatch(protocol::SqliteExecuteBatchResponse), } #[derive(Debug)] @@ -53,6 +55,7 @@ impl RemoteSqliteRequest { match self { RemoteSqliteRequest::Exec(_) => "exec", RemoteSqliteRequest::Execute(_) => "execute", + RemoteSqliteRequest::ExecuteBatch(_) => "execute_batch", } } @@ -60,6 +63,7 @@ impl RemoteSqliteRequest { match self { RemoteSqliteRequest::Exec(_) => "remote_exec", RemoteSqliteRequest::Execute(_) => "remote_execute", + RemoteSqliteRequest::ExecuteBatch(_) => "remote_execute_batch", } } } @@ -180,6 +184,18 @@ pub async fn handle_remote_sqlite_execute_response( ); } +pub async fn handle_remote_sqlite_execute_batch_response( + ctx: &mut EnvoyContext, + response: protocol::ToEnvoySqliteExecuteBatchResponse, +) { + handle_remote_sqlite_response( + ctx, + response.request_id, + RemoteSqliteResponse::ExecuteBatch(response.data), + "remote_sqlite_execute_batch", + ); +} + fn handle_sqlite_response( ctx: &mut EnvoyContext, request_id: u32, @@ -315,6 +331,11 @@ pub fn remote_sqlite_request_to_message( data, }) } + RemoteSqliteRequest::ExecuteBatch(data) => { + protocol::ToRivet::ToRivetSqliteExecuteBatchRequest( + protocol::ToRivetSqliteExecuteBatchRequest { request_id, data }, + ) + } } } @@ -611,6 +632,28 @@ mod tests { } } + fn execute_batch_request() -> protocol::SqliteExecuteBatchRequest { + protocol::SqliteExecuteBatchRequest { + namespace_id: "ns".to_string(), + actor_id: "actor".to_string(), + generation: 1, + statements: vec![ + protocol::SqliteBatchStatement { + sql: "insert into t values (?)".to_string(), + params: Some(vec![protocol::SqliteBindParam::SqliteValueInteger( + protocol::SqliteValueInteger { value: 1 }, + )]), + }, + protocol::SqliteBatchStatement { + sql: "insert into t values (?)".to_string(), + params: Some(vec![protocol::SqliteBindParam::SqliteValueInteger( + protocol::SqliteValueInteger { value: 2 }, + )]), + }, + ], + } + } + #[tokio::test] async fn remote_sqlite_exec_response_matches_pending_request() { let mut ctx = new_envoy_context(); @@ -659,6 +702,29 @@ mod tests { assert!(ctx.remote_sqlite_requests.is_empty()); } + #[tokio::test] + async fn remote_sqlite_batch_uses_one_websocket_request() { + let mut ctx = new_envoy_context(); + let (ws_tx, mut ws_rx) = tokio::sync::mpsc::unbounded_channel(); + crate::connection::install_connection(&ctx.shared, ws_tx).await; + let (tx, _rx) = oneshot::channel(); + + handle_remote_sqlite_request( + &mut ctx, + RemoteSqliteRequest::ExecuteBatch(execute_batch_request()), + None, + tx, + ) + .await; + + assert!(matches!(ws_rx.recv().await, Some(WsTxMessage::Send(_)))); + assert!( + ws_rx.try_recv().is_err(), + "a batch must serialize as one WebSocket message" + ); + assert_eq!(ctx.remote_sqlite_requests.len(), 1); + } + #[test] fn remote_sqlite_requests_reject_protocol_v3_serialization() { let requests = vec![ diff --git a/engine/sdks/rust/envoy-client/src/stringify.rs b/engine/sdks/rust/envoy-client/src/stringify.rs index 203d447329..e4505a4478 100644 --- a/engine/sdks/rust/envoy-client/src/stringify.rs +++ b/engine/sdks/rust/envoy-client/src/stringify.rs @@ -287,6 +287,15 @@ pub fn stringify_to_rivet(message: &protocol::ToRivet) -> String { val.request_id, val.data.actor_id, val.data.generation ) } + protocol::ToRivet::ToRivetSqliteExecuteBatchRequest(val) => { + format!( + "ToRivetSqliteExecuteBatchRequest{{requestId: {}, actorId: \"{}\", generation: {}, statements: {}}}", + val.request_id, + val.data.actor_id, + val.data.generation, + val.data.statements.len() + ) + } protocol::ToRivet::ToRivetTunnelMessage(val) => { format!( "ToRivetTunnelMessage{{messageId: {}, messageKind: {}}}", @@ -348,6 +357,12 @@ pub fn stringify_to_envoy(message: &protocol::ToEnvoy) -> String { val.request_id ) } + protocol::ToEnvoy::ToEnvoySqliteExecuteBatchResponse(val) => { + format!( + "ToEnvoySqliteExecuteBatchResponse{{requestId: {}}}", + val.request_id + ) + } protocol::ToEnvoy::ToEnvoyTunnelMessage(val) => { format!( "ToEnvoyTunnelMessage{{messageId: {}, messageKind: {}}}", diff --git a/engine/sdks/rust/envoy-protocol/schemas/v6.bare b/engine/sdks/rust/envoy-protocol/schemas/v6.bare new file mode 100644 index 0000000000..0e41e261b1 --- /dev/null +++ b/engine/sdks/rust/envoy-protocol/schemas/v6.bare @@ -0,0 +1,675 @@ +# MARK: Core Primitives + +type Id str +type Json str + +type GatewayId data[4] +type RequestId data[4] +type MessageIndex u16 + +# MARK: KV + +# Basic types +type KvKey data +type KvValue data +type KvMetadata struct { + version: data + updateTs: i64 +} + +# Query types +type KvListAllQuery void +type KvListRangeQuery struct { + start: KvKey + end: KvKey + exclusive: bool +} + +type KvListPrefixQuery struct { + key: KvKey +} + +type KvListQuery union { + KvListAllQuery | + KvListRangeQuery | + KvListPrefixQuery +} + +# Request types +type KvGetRequest struct { + keys: list +} + +type KvListRequest struct { + query: KvListQuery + reverse: optional + limit: optional +} + +type KvPutRequest struct { + keys: list + values: list +} + +type KvDeleteRequest struct { + keys: list +} + +type KvDeleteRangeRequest struct { + start: KvKey + end: KvKey +} + +type KvDropRequest void + +# Response types +type KvErrorResponse struct { + message: str +} + +type KvGetResponse struct { + keys: list + values: list + metadata: list +} + +type KvListResponse struct { + keys: list + values: list + metadata: list +} + +type KvPutResponse void +type KvDeleteResponse void +type KvDropResponse void + +# Request/Response unions +type KvRequestData union { + KvGetRequest | + KvListRequest | + KvPutRequest | + KvDeleteRequest | + KvDeleteRangeRequest | + KvDropRequest +} + +type KvResponseData union { + KvErrorResponse | + KvGetResponse | + KvListResponse | + KvPutResponse | + KvDeleteResponse | + KvDropResponse +} + +# MARK: SQLite + +type SqlitePgno u32 +type SqliteGeneration u64 +type SqlitePageBytes data + +type SqliteDirtyPage struct { + pgno: SqlitePgno + bytes: SqlitePageBytes +} + +type SqliteFetchedPage struct { + pgno: SqlitePgno + bytes: optional +} + +type SqliteGetPagesRequest struct { + actorId: Id + pgnos: list + expectedGeneration: optional + expectedHeadTxid: optional +} + +type SqliteGetPagesOk struct { + pages: list + headTxid: optional +} + +type SqliteErrorResponse struct { + group: str + code: str + message: str +} + +type SqliteGetPagesResponse union { + SqliteGetPagesOk | + SqliteErrorResponse +} + +type SqliteCommitRequest struct { + actorId: Id + dirtyPages: list + dbSizePages: u32 + nowMs: i64 + expectedGeneration: optional + expectedHeadTxid: optional +} + +type SqliteCommitOk struct { + headTxid: optional +} + +type SqliteCommitResponse union { + SqliteCommitOk | + SqliteErrorResponse +} + +# MARK: SQLite Remote Execution + +type SqliteValueNull void + +type SqliteValueInteger struct { + value: i64 +} + +type SqliteValueFloat struct { + value: data[8] +} + +type SqliteValueText struct { + value: str +} + +type SqliteValueBlob struct { + value: data +} + +type SqliteBindParam union { + SqliteValueNull | + SqliteValueInteger | + SqliteValueFloat | + SqliteValueText | + SqliteValueBlob +} + +type SqliteColumnValue union { + SqliteValueNull | + SqliteValueInteger | + SqliteValueFloat | + SqliteValueText | + SqliteValueBlob +} + +type SqliteQueryResult struct { + columns: list + rows: list> +} + +type SqliteExecuteResult struct { + columns: list + rows: list> + changes: i64 + lastInsertRowId: optional +} + +type SqliteExecRequest struct { + namespaceId: Id + actorId: Id + generation: SqliteGeneration + sql: str +} + +type SqliteExecuteRequest struct { + namespaceId: Id + actorId: Id + generation: SqliteGeneration + sql: str + params: optional> +} + +type SqliteBatchStatement struct { + sql: str + params: optional> +} + +type SqliteExecuteBatchRequest struct { + namespaceId: Id + actorId: Id + generation: SqliteGeneration + statements: list +} + +type SqliteExecOk struct { + result: SqliteQueryResult +} + +type SqliteExecuteOk struct { + result: SqliteExecuteResult +} + +type SqliteExecuteBatchOk struct { + results: list +} + +type SqliteExecResponse union { + SqliteExecOk | + SqliteErrorResponse +} + +type SqliteExecuteResponse union { + SqliteExecuteOk | + SqliteErrorResponse +} + +type SqliteExecuteBatchResponse union { + SqliteExecuteBatchOk | + SqliteErrorResponse +} + +# MARK: Actor + +# Core +type StopCode enum { + OK + ERROR +} + +type ActorName struct { + metadata: Json +} + +type ActorConfig struct { + name: str + key: optional + createTs: i64 + input: optional +} + +type ActorCheckpoint struct { + actorId: Id + generation: u32 + index: i64 +} + +# Intent +type ActorIntentSleep void + +type ActorIntentStop void + +type ActorIntent union { + ActorIntentSleep | + ActorIntentStop +} + +# State +type ActorStateRunning void + +type ActorStateStopped struct { + code: StopCode + message: optional +} + +type ActorState union { + ActorStateRunning | + ActorStateStopped +} + +# MARK: Events +type EventActorIntent struct { + intent: ActorIntent +} + +type EventActorStateUpdate struct { + state: ActorState +} + +type EventActorSetAlarm struct { + alarmTs: optional +} + +type Event union { + EventActorIntent | + EventActorStateUpdate | + EventActorSetAlarm +} + +type EventWrapper struct { + checkpoint: ActorCheckpoint + inner: Event +} + +# MARK: Preloaded KV + +type PreloadedKvEntry struct { + key: KvKey + value: KvValue + metadata: KvMetadata +} + +type PreloadedKv struct { + entries: list + requestedGetKeys: list + requestedPrefixes: list +} + +# MARK: Commands + +type HibernatingRequest struct { + gatewayId: GatewayId + requestId: RequestId +} + +type CommandStartActor struct { + config: ActorConfig + hibernatingRequests: list + preloadedKv: optional +} + +type StopActorReason enum { + SLEEP_INTENT + STOP_INTENT + DESTROY + GOING_AWAY + LOST +} + +type CommandStopActor struct { + reason: StopActorReason +} + +type Command union { + CommandStartActor | + CommandStopActor +} + +type CommandWrapper struct { + checkpoint: ActorCheckpoint + inner: Command +} + +# We redeclare this so its top level +type ActorCommandKeyData union { + CommandStartActor | + CommandStopActor +} + +# MARK: Tunnel + +# Message ID + +type MessageId struct { + # Globally unique ID + gatewayId: GatewayId + # Unique ID to the gateway + requestId: RequestId + # Unique ID to the request + messageIndex: MessageIndex +} + +# HTTP +type ToEnvoyRequestStart struct { + actorId: Id + method: str + path: str + headers: map + body: optional + stream: bool +} + +type ToEnvoyRequestChunk struct { + body: data + finish: bool +} + +type ToEnvoyRequestAbort void + +type ToRivetResponseStart struct { + status: u16 + headers: map + body: optional + stream: bool +} + +type ToRivetResponseChunk struct { + body: data + finish: bool +} + +type ToRivetResponseAbort void + +# WebSocket +type ToEnvoyWebSocketOpen struct { + actorId: Id + path: str + headers: map +} + +type ToEnvoyWebSocketMessage struct { + data: data + binary: bool +} + +type ToEnvoyWebSocketClose struct { + code: optional + reason: optional +} + +type ToRivetWebSocketOpen struct { + canHibernate: bool +} + +type ToRivetWebSocketMessage struct { + data: data + binary: bool +} + +type ToRivetWebSocketMessageAck struct { + index: MessageIndex +} + +type ToRivetWebSocketClose struct { + code: optional + reason: optional + hibernate: bool +} + +# To Rivet +type ToRivetTunnelMessageKind union { + # HTTP + ToRivetResponseStart | + ToRivetResponseChunk | + ToRivetResponseAbort | + + # WebSocket + ToRivetWebSocketOpen | + ToRivetWebSocketMessage | + ToRivetWebSocketMessageAck | + ToRivetWebSocketClose +} + +type ToRivetTunnelMessage struct { + messageId: MessageId + messageKind: ToRivetTunnelMessageKind +} + +# To Envoy +type ToEnvoyTunnelMessageKind union { + # HTTP + ToEnvoyRequestStart | + ToEnvoyRequestChunk | + ToEnvoyRequestAbort | + + # WebSocket + ToEnvoyWebSocketOpen | + ToEnvoyWebSocketMessage | + ToEnvoyWebSocketClose +} + +type ToEnvoyTunnelMessage struct { + messageId: MessageId + messageKind: ToEnvoyTunnelMessageKind +} + +type ToEnvoyPing struct { + ts: i64 +} + +# MARK: To Rivet +type ToRivetMetadata struct { + prepopulateActorNames: optional> + metadata: optional +} + +type ToRivetEvents list + +type ToRivetAckCommands struct { + lastCommandCheckpoints: list +} + +type ToRivetStopping void + +type ToRivetPong struct { + ts: i64 +} + +type ToRivetKvRequest struct { + actorId: Id + requestId: u32 + data: KvRequestData +} + +type ToRivetSqliteGetPagesRequest struct { + requestId: u32 + data: SqliteGetPagesRequest +} + +type ToRivetSqliteCommitRequest struct { + requestId: u32 + data: SqliteCommitRequest +} + +type ToRivetSqliteExecRequest struct { + requestId: u32 + data: SqliteExecRequest +} + +type ToRivetSqliteExecuteRequest struct { + requestId: u32 + data: SqliteExecuteRequest +} + +type ToRivetSqliteExecuteBatchRequest struct { + requestId: u32 + data: SqliteExecuteBatchRequest +} + +type ToRivet union { + ToRivetMetadata | + ToRivetEvents | + ToRivetAckCommands | + ToRivetStopping | + ToRivetPong | + ToRivetKvRequest | + ToRivetTunnelMessage | + ToRivetSqliteGetPagesRequest | + ToRivetSqliteCommitRequest | + ToRivetSqliteExecRequest | + ToRivetSqliteExecuteRequest | + ToRivetSqliteExecuteBatchRequest +} + +# MARK: To Envoy +type ProtocolMetadata struct { + envoyLostThreshold: i64 + actorStopThreshold: i64 + maxResponsePayloadSize: u64 +} + +type ToEnvoyInit struct { + metadata: ProtocolMetadata +} + +type ToEnvoyCommands list + +type ToEnvoyAckEvents struct { + lastEventCheckpoints: list +} + +type ToEnvoyKvResponse struct { + requestId: u32 + data: KvResponseData +} + +type ToEnvoySqliteGetPagesResponse struct { + requestId: u32 + data: SqliteGetPagesResponse +} + +type ToEnvoySqliteCommitResponse struct { + requestId: u32 + data: SqliteCommitResponse +} + +type ToEnvoySqliteExecResponse struct { + requestId: u32 + data: SqliteExecResponse +} + +type ToEnvoySqliteExecuteResponse struct { + requestId: u32 + data: SqliteExecuteResponse +} + +type ToEnvoySqliteExecuteBatchResponse struct { + requestId: u32 + data: SqliteExecuteBatchResponse +} + +type ToEnvoy union { + ToEnvoyInit | + ToEnvoyCommands | + ToEnvoyAckEvents | + ToEnvoyKvResponse | + ToEnvoyTunnelMessage | + ToEnvoyPing | + ToEnvoySqliteGetPagesResponse | + ToEnvoySqliteCommitResponse | + ToEnvoySqliteExecResponse | + ToEnvoySqliteExecuteResponse | + ToEnvoySqliteExecuteBatchResponse +} + +# MARK: To Envoy Conn +type ToEnvoyConnPing struct { + gatewayId: GatewayId + requestId: RequestId + ts: i64 +} + +type ToEnvoyConnClose void + +type ToEnvoyConn union { + ToEnvoyConnPing | + ToEnvoyConnClose | + ToEnvoyCommands | + ToEnvoyAckEvents | + ToEnvoyTunnelMessage +} + +# MARK: To Gateway +type ToGatewayPong struct { + requestId: RequestId + ts: i64 +} + +type ToGateway union { + ToGatewayPong | + ToRivetTunnelMessage +} + +# MARK: To Outbound +type ToOutboundActorStart struct { + namespaceId: Id + poolName: str + checkpoint: ActorCheckpoint + actorConfig: ActorConfig +} + +type ToOutbound union { + ToOutboundActorStart +} diff --git a/engine/sdks/rust/envoy-protocol/src/lib.rs b/engine/sdks/rust/envoy-protocol/src/lib.rs index 87d6be2058..94a5a3228f 100644 --- a/engine/sdks/rust/envoy-protocol/src/lib.rs +++ b/engine/sdks/rust/envoy-protocol/src/lib.rs @@ -3,6 +3,6 @@ pub mod util; pub mod versioned; // Re-export latest -pub use generated::v5::*; +pub use generated::v6::*; pub use generated::PROTOCOL_VERSION; diff --git a/engine/sdks/rust/envoy-protocol/src/versioned/mod.rs b/engine/sdks/rust/envoy-protocol/src/versioned/mod.rs index 7f945fc9b9..44494c150e 100644 --- a/engine/sdks/rust/envoy-protocol/src/versioned/mod.rs +++ b/engine/sdks/rust/envoy-protocol/src/versioned/mod.rs @@ -3,7 +3,7 @@ use std::{error::Error, fmt}; use anyhow::{Result, bail}; use vbare::OwnedVersionedData; -use crate::generated::{v1, v2, v3, v4, v5}; +use crate::generated::{v1, v2, v3, v4, v5, v6}; mod v1_to_v2; mod v2_to_v1; @@ -13,6 +13,8 @@ mod v3_to_v4; mod v4_to_v3; mod v4_to_v5; mod v5_to_v4; +mod v5_to_v6; +mod v6_to_v5; // MARK: Protocol compatibility errors @@ -22,6 +24,7 @@ pub enum ProtocolCompatibilityFeature { SqlitePageIo, SqlitePageRange, RemoteSqliteExecution, + RemoteSqliteBatchExecution, } impl ProtocolCompatibilityFeature { @@ -40,6 +43,10 @@ impl ProtocolCompatibilityFeature { ProtocolCompatibilityDirection::ToEnvoy => "remote sqlite responses", ProtocolCompatibilityDirection::ToRivet => "remote sqlite requests", }, + ProtocolCompatibilityFeature::RemoteSqliteBatchExecution => match direction { + ProtocolCompatibilityDirection::ToEnvoy => "remote sqlite batch responses", + ProtocolCompatibilityDirection::ToRivet => "remote sqlite batch requests", + }, } } } @@ -65,6 +72,7 @@ impl fmt::Display for ProtocolCompatibilityError { ProtocolCompatibilityFeature::SqlitePageIo | ProtocolCompatibilityFeature::SqlitePageRange | ProtocolCompatibilityFeature::RemoteSqliteExecution => "require", + ProtocolCompatibilityFeature::RemoteSqliteBatchExecution => "require", }; write!( f, @@ -102,18 +110,19 @@ pub enum ToEnvoy { V3(v3::ToEnvoy), V4(v4::ToEnvoy), V5(v5::ToEnvoy), + V6(v6::ToEnvoy), } impl OwnedVersionedData for ToEnvoy { - type Latest = v5::ToEnvoy; + type Latest = v6::ToEnvoy; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V5(latest) + Self::V6(latest) } fn unwrap_latest(self) -> Result { match self { - Self::V5(x) => Ok(x), + Self::V6(x) => Ok(x), _ => bail!("version not latest"), } } @@ -125,6 +134,7 @@ impl OwnedVersionedData for ToEnvoy { 3 => Ok(Self::V3(serde_bare::from_slice(payload)?)), 4 => Ok(Self::V4(serde_bare::from_slice(payload)?)), 5 => Ok(Self::V5(serde_bare::from_slice(payload)?)), + 6 => Ok(Self::V6(serde_bare::from_slice(payload)?)), _ => bail!("invalid version: {version}"), } } @@ -136,6 +146,7 @@ impl OwnedVersionedData for ToEnvoy { Self::V3(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V4(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V5(x) => serde_bare::to_vec(&x).map_err(Into::into), + Self::V6(x) => serde_bare::to_vec(&x).map_err(Into::into), } } @@ -145,11 +156,13 @@ impl OwnedVersionedData for ToEnvoy { Self::v2_to_v3, Self::v3_to_v4, Self::v4_to_v5, + Self::v5_to_v6, ] } fn serialize_converters() -> Vec Result> { vec![ + Self::v6_to_v5, Self::v5_to_v4, Self::v4_to_v3, Self::v3_to_v2, @@ -207,6 +220,18 @@ impl ToEnvoy { _ => bail!("unexpected version"), } } + fn v5_to_v6(self) -> Result { + match self { + Self::V5(x) => Ok(Self::V6(v5_to_v6::convert_to_envoy_v5_to_v6(x)?)), + _ => bail!("unexpected version"), + } + } + fn v6_to_v5(self) -> Result { + match self { + Self::V6(x) => Ok(Self::V5(v6_to_v5::convert_to_envoy_v6_to_v5(x)?)), + _ => bail!("unexpected version"), + } + } } // MARK: ToRivet @@ -217,18 +242,19 @@ pub enum ToRivet { V3(v3::ToRivet), V4(v4::ToRivet), V5(v5::ToRivet), + V6(v6::ToRivet), } impl OwnedVersionedData for ToRivet { - type Latest = v5::ToRivet; + type Latest = v6::ToRivet; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V5(latest) + Self::V6(latest) } fn unwrap_latest(self) -> Result { match self { - Self::V5(x) => Ok(x), + Self::V6(x) => Ok(x), _ => bail!("version not latest"), } } @@ -240,6 +266,7 @@ impl OwnedVersionedData for ToRivet { 3 => Ok(Self::V3(serde_bare::from_slice(payload)?)), 4 => Ok(Self::V4(serde_bare::from_slice(payload)?)), 5 => Ok(Self::V5(serde_bare::from_slice(payload)?)), + 6 => Ok(Self::V6(serde_bare::from_slice(payload)?)), _ => bail!("invalid version: {version}"), } } @@ -251,6 +278,7 @@ impl OwnedVersionedData for ToRivet { Self::V3(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V4(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V5(x) => serde_bare::to_vec(&x).map_err(Into::into), + Self::V6(x) => serde_bare::to_vec(&x).map_err(Into::into), } } @@ -260,11 +288,13 @@ impl OwnedVersionedData for ToRivet { Self::v2_to_v3, Self::v3_to_v4, Self::v4_to_v5, + Self::v5_to_v6, ] } fn serialize_converters() -> Vec Result> { vec![ + Self::v6_to_v5, Self::v5_to_v4, Self::v4_to_v3, Self::v3_to_v2, @@ -322,6 +352,18 @@ impl ToRivet { _ => bail!("unexpected version"), } } + fn v5_to_v6(self) -> Result { + match self { + Self::V5(x) => Ok(Self::V6(v5_to_v6::convert_to_rivet_v5_to_v6(x)?)), + _ => bail!("unexpected version"), + } + } + fn v6_to_v5(self) -> Result { + match self { + Self::V6(x) => Ok(Self::V5(v6_to_v5::convert_to_rivet_v6_to_v5(x)?)), + _ => bail!("unexpected version"), + } + } } // MARK: ToEnvoyConn @@ -332,18 +374,19 @@ pub enum ToEnvoyConn { V3(v3::ToEnvoyConn), V4(v4::ToEnvoyConn), V5(v5::ToEnvoyConn), + V6(v6::ToEnvoyConn), } impl OwnedVersionedData for ToEnvoyConn { - type Latest = v5::ToEnvoyConn; + type Latest = v6::ToEnvoyConn; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V5(latest) + Self::V6(latest) } fn unwrap_latest(self) -> Result { match self { - Self::V5(x) => Ok(x), + Self::V6(x) => Ok(x), _ => bail!("version not latest"), } } @@ -355,6 +398,7 @@ impl OwnedVersionedData for ToEnvoyConn { 3 => Ok(Self::V3(serde_bare::from_slice(payload)?)), 4 => Ok(Self::V4(serde_bare::from_slice(payload)?)), 5 => Ok(Self::V5(serde_bare::from_slice(payload)?)), + 6 => Ok(Self::V6(serde_bare::from_slice(payload)?)), _ => bail!("invalid version: {version}"), } } @@ -366,6 +410,7 @@ impl OwnedVersionedData for ToEnvoyConn { Self::V3(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V4(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V5(x) => serde_bare::to_vec(&x).map_err(Into::into), + Self::V6(x) => serde_bare::to_vec(&x).map_err(Into::into), } } @@ -375,11 +420,13 @@ impl OwnedVersionedData for ToEnvoyConn { Self::v2_to_v3, Self::v3_to_v4, Self::v4_to_v5, + Self::v5_to_v6, ] } fn serialize_converters() -> Vec Result> { vec![ + Self::v6_to_v5, Self::v5_to_v4, Self::v4_to_v3, Self::v3_to_v2, @@ -437,6 +484,18 @@ impl ToEnvoyConn { _ => bail!("unexpected version"), } } + fn v5_to_v6(self) -> Result { + match self { + Self::V5(x) => Ok(Self::V6(v5_to_v6::convert_to_envoy_conn_v5_to_v6(x)?)), + _ => bail!("unexpected version"), + } + } + fn v6_to_v5(self) -> Result { + match self { + Self::V6(x) => Ok(Self::V5(v6_to_v5::convert_to_envoy_conn_v6_to_v5(x)?)), + _ => bail!("unexpected version"), + } + } } // MARK: ToGateway @@ -447,18 +506,19 @@ pub enum ToGateway { V3(v3::ToGateway), V4(v4::ToGateway), V5(v5::ToGateway), + V6(v6::ToGateway), } impl OwnedVersionedData for ToGateway { - type Latest = v5::ToGateway; + type Latest = v6::ToGateway; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V5(latest) + Self::V6(latest) } fn unwrap_latest(self) -> Result { match self { - Self::V5(x) => Ok(x), + Self::V6(x) => Ok(x), _ => bail!("version not latest"), } } @@ -470,6 +530,7 @@ impl OwnedVersionedData for ToGateway { 3 => Ok(Self::V3(serde_bare::from_slice(payload)?)), 4 => Ok(Self::V4(serde_bare::from_slice(payload)?)), 5 => Ok(Self::V5(serde_bare::from_slice(payload)?)), + 6 => Ok(Self::V6(serde_bare::from_slice(payload)?)), _ => bail!("invalid version: {version}"), } } @@ -481,6 +542,7 @@ impl OwnedVersionedData for ToGateway { Self::V3(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V4(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V5(x) => serde_bare::to_vec(&x).map_err(Into::into), + Self::V6(x) => serde_bare::to_vec(&x).map_err(Into::into), } } @@ -490,11 +552,13 @@ impl OwnedVersionedData for ToGateway { Self::v2_to_v3, Self::v3_to_v4, Self::v4_to_v5, + Self::v5_to_v6, ] } fn serialize_converters() -> Vec Result> { vec![ + Self::v6_to_v5, Self::v5_to_v4, Self::v4_to_v3, Self::v3_to_v2, @@ -552,6 +616,18 @@ impl ToGateway { _ => bail!("unexpected version"), } } + fn v5_to_v6(self) -> Result { + match self { + Self::V5(x) => Ok(Self::V6(v5_to_v6::convert_to_gateway_v5_to_v6(x)?)), + _ => bail!("unexpected version"), + } + } + fn v6_to_v5(self) -> Result { + match self { + Self::V6(x) => Ok(Self::V5(v6_to_v5::convert_to_gateway_v6_to_v5(x)?)), + _ => bail!("unexpected version"), + } + } } // MARK: ToOutbound @@ -562,18 +638,19 @@ pub enum ToOutbound { V3(v3::ToOutbound), V4(v4::ToOutbound), V5(v5::ToOutbound), + V6(v6::ToOutbound), } impl OwnedVersionedData for ToOutbound { - type Latest = v5::ToOutbound; + type Latest = v6::ToOutbound; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V5(latest) + Self::V6(latest) } fn unwrap_latest(self) -> Result { match self { - Self::V5(x) => Ok(x), + Self::V6(x) => Ok(x), _ => bail!("version not latest"), } } @@ -585,6 +662,7 @@ impl OwnedVersionedData for ToOutbound { 3 => Ok(Self::V3(serde_bare::from_slice(payload)?)), 4 => Ok(Self::V4(serde_bare::from_slice(payload)?)), 5 => Ok(Self::V5(serde_bare::from_slice(payload)?)), + 6 => Ok(Self::V6(serde_bare::from_slice(payload)?)), _ => bail!("invalid version: {version}"), } } @@ -596,6 +674,7 @@ impl OwnedVersionedData for ToOutbound { Self::V3(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V4(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V5(x) => serde_bare::to_vec(&x).map_err(Into::into), + Self::V6(x) => serde_bare::to_vec(&x).map_err(Into::into), } } @@ -605,11 +684,13 @@ impl OwnedVersionedData for ToOutbound { Self::v2_to_v3, Self::v3_to_v4, Self::v4_to_v5, + Self::v5_to_v6, ] } fn serialize_converters() -> Vec Result> { vec![ + Self::v6_to_v5, Self::v5_to_v4, Self::v4_to_v3, Self::v3_to_v2, @@ -667,6 +748,18 @@ impl ToOutbound { _ => bail!("unexpected version"), } } + fn v5_to_v6(self) -> Result { + match self { + Self::V5(x) => Ok(Self::V6(v5_to_v6::convert_to_outbound_v5_to_v6(x)?)), + _ => bail!("unexpected version"), + } + } + fn v6_to_v5(self) -> Result { + match self { + Self::V6(x) => Ok(Self::V5(v6_to_v5::convert_to_outbound_v6_to_v5(x)?)), + _ => bail!("unexpected version"), + } + } } // MARK: ActorCommandKeyData @@ -677,18 +770,19 @@ pub enum ActorCommandKeyData { V3(v3::ActorCommandKeyData), V4(v4::ActorCommandKeyData), V5(v5::ActorCommandKeyData), + V6(v6::ActorCommandKeyData), } impl OwnedVersionedData for ActorCommandKeyData { - type Latest = v5::ActorCommandKeyData; + type Latest = v6::ActorCommandKeyData; fn wrap_latest(latest: Self::Latest) -> Self { - Self::V5(latest) + Self::V6(latest) } fn unwrap_latest(self) -> Result { match self { - Self::V5(x) => Ok(x), + Self::V6(x) => Ok(x), _ => bail!("version not latest"), } } @@ -700,6 +794,7 @@ impl OwnedVersionedData for ActorCommandKeyData { 3 => Ok(Self::V3(serde_bare::from_slice(payload)?)), 4 => Ok(Self::V4(serde_bare::from_slice(payload)?)), 5 => Ok(Self::V5(serde_bare::from_slice(payload)?)), + 6 => Ok(Self::V6(serde_bare::from_slice(payload)?)), _ => bail!("invalid version: {version}"), } } @@ -711,6 +806,7 @@ impl OwnedVersionedData for ActorCommandKeyData { Self::V3(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V4(x) => serde_bare::to_vec(&x).map_err(Into::into), Self::V5(x) => serde_bare::to_vec(&x).map_err(Into::into), + Self::V6(x) => serde_bare::to_vec(&x).map_err(Into::into), } } @@ -720,11 +816,13 @@ impl OwnedVersionedData for ActorCommandKeyData { Self::v2_to_v3, Self::v3_to_v4, Self::v4_to_v5, + Self::v5_to_v6, ] } fn serialize_converters() -> Vec Result> { vec![ + Self::v6_to_v5, Self::v5_to_v4, Self::v4_to_v3, Self::v3_to_v2, @@ -798,6 +896,18 @@ impl ActorCommandKeyData { _ => bail!("unexpected version"), } } + fn v5_to_v6(self) -> Result { + match self { + Self::V5(x) => Ok(Self::V6(v5_to_v6::convert_actor_command_key_data_v5_to_v6(x)?)), + _ => bail!("unexpected version"), + } + } + fn v6_to_v5(self) -> Result { + match self { + Self::V6(x) => Ok(Self::V5(v6_to_v5::convert_actor_command_key_data_v6_to_v5(x)?)), + _ => bail!("unexpected version"), + } + } } // MARK: Tests @@ -810,16 +920,16 @@ mod tests { use super::{ActorCommandKeyData, ToEnvoy}; use crate::{ PROTOCOL_VERSION, - generated::{v1, v2, v5}, + generated::{v1, v2, v6}, }; #[test] fn protocol_version_constant_matches_schema_version() { - assert_eq!(PROTOCOL_VERSION, 5); + assert_eq!(PROTOCOL_VERSION, 6); } #[test] - fn v1_start_command_deserializes_into_v3_without_sqlite_startup_data() -> Result<()> { + fn v1_start_command_deserializes_into_latest_without_sqlite_startup_data() -> Result<()> { let payload = serde_bare::to_vec(&v1::ToEnvoy::ToEnvoyCommands(vec![v1::CommandWrapper { checkpoint: v1::ActorCheckpoint { @@ -840,10 +950,10 @@ mod tests { }]))?; let decoded = ToEnvoy::deserialize(&payload, 1)?; - let v5::ToEnvoy::ToEnvoyCommands(commands) = decoded else { + let v6::ToEnvoy::ToEnvoyCommands(commands) = decoded else { panic!("expected commands"); }; - let v5::Command::CommandStartActor(start) = &commands[0].inner else { + let v6::Command::CommandStartActor(start) = &commands[0].inner else { panic!("expected start actor"); }; @@ -870,9 +980,9 @@ mod tests { #[test] fn actor_command_key_data_round_trips_to_v1() -> Result<()> { - let encoded = ActorCommandKeyData::wrap_latest(v5::ActorCommandKeyData::CommandStartActor( - v5::CommandStartActor { - config: v5::ActorConfig { + let encoded = ActorCommandKeyData::wrap_latest(v6::ActorCommandKeyData::CommandStartActor( + v6::CommandStartActor { + config: v6::ActorConfig { name: "demo".into(), key: None, create_ts: 7, @@ -885,7 +995,7 @@ mod tests { .serialize(1)?; let decoded = ActorCommandKeyData::deserialize(&encoded, 1)?; - let v5::ActorCommandKeyData::CommandStartActor(start) = decoded else { + let v6::ActorCommandKeyData::CommandStartActor(start) = decoded else { panic!("expected start actor"); }; assert_eq!(start.config.name, "demo"); diff --git a/engine/sdks/rust/envoy-protocol/src/versioned/v5_to_v6.rs b/engine/sdks/rust/envoy-protocol/src/versioned/v5_to_v6.rs new file mode 100644 index 0000000000..2cbd5380d4 --- /dev/null +++ b/engine/sdks/rust/envoy-protocol/src/versioned/v5_to_v6.rs @@ -0,0 +1,770 @@ +// from: v5.bare, to: v6.bare + +#![allow(dead_code, unused_variables)] + +use anyhow::Result; + +use crate::generated::{v5, v6}; + +pub fn convert_kv_metadata_v5_to_v6(x: v5::KvMetadata) -> Result { + Ok(v6::KvMetadata { + version: x.version, + update_ts: x.update_ts, + }) +} + +pub fn convert_kv_list_range_query_v5_to_v6(x: v5::KvListRangeQuery) -> Result { + Ok(v6::KvListRangeQuery { + start: x.start, + end: x.end, + exclusive: x.exclusive, + }) +} + +pub fn convert_kv_list_prefix_query_v5_to_v6(x: v5::KvListPrefixQuery) -> Result { + Ok(v6::KvListPrefixQuery { + key: x.key, + }) +} + +pub fn convert_kv_list_query_v5_to_v6(x: v5::KvListQuery) -> Result { + Ok(match x { + v5::KvListQuery::KvListAllQuery => v6::KvListQuery::KvListAllQuery, + v5::KvListQuery::KvListRangeQuery(v) => v6::KvListQuery::KvListRangeQuery(convert_kv_list_range_query_v5_to_v6(v)?), + v5::KvListQuery::KvListPrefixQuery(v) => v6::KvListQuery::KvListPrefixQuery(convert_kv_list_prefix_query_v5_to_v6(v)?), + }) +} + +pub fn convert_kv_get_request_v5_to_v6(x: v5::KvGetRequest) -> Result { + Ok(v6::KvGetRequest { + keys: x.keys, + }) +} + +pub fn convert_kv_list_request_v5_to_v6(x: v5::KvListRequest) -> Result { + Ok(v6::KvListRequest { + query: convert_kv_list_query_v5_to_v6(x.query)?, + reverse: x.reverse, + limit: x.limit, + }) +} + +pub fn convert_kv_put_request_v5_to_v6(x: v5::KvPutRequest) -> Result { + Ok(v6::KvPutRequest { + keys: x.keys, + values: x.values, + }) +} + +pub fn convert_kv_delete_request_v5_to_v6(x: v5::KvDeleteRequest) -> Result { + Ok(v6::KvDeleteRequest { + keys: x.keys, + }) +} + +pub fn convert_kv_delete_range_request_v5_to_v6(x: v5::KvDeleteRangeRequest) -> Result { + Ok(v6::KvDeleteRangeRequest { + start: x.start, + end: x.end, + }) +} + +pub fn convert_kv_error_response_v5_to_v6(x: v5::KvErrorResponse) -> Result { + Ok(v6::KvErrorResponse { + message: x.message, + }) +} + +pub fn convert_kv_get_response_v5_to_v6(x: v5::KvGetResponse) -> Result { + Ok(v6::KvGetResponse { + keys: x.keys, + values: x.values, + metadata: x.metadata.into_iter().map(|v| convert_kv_metadata_v5_to_v6(v)).collect::>>()?, + }) +} + +pub fn convert_kv_list_response_v5_to_v6(x: v5::KvListResponse) -> Result { + Ok(v6::KvListResponse { + keys: x.keys, + values: x.values, + metadata: x.metadata.into_iter().map(|v| convert_kv_metadata_v5_to_v6(v)).collect::>>()?, + }) +} + +pub fn convert_kv_request_data_v5_to_v6(x: v5::KvRequestData) -> Result { + Ok(match x { + v5::KvRequestData::KvGetRequest(v) => v6::KvRequestData::KvGetRequest(convert_kv_get_request_v5_to_v6(v)?), + v5::KvRequestData::KvListRequest(v) => v6::KvRequestData::KvListRequest(convert_kv_list_request_v5_to_v6(v)?), + v5::KvRequestData::KvPutRequest(v) => v6::KvRequestData::KvPutRequest(convert_kv_put_request_v5_to_v6(v)?), + v5::KvRequestData::KvDeleteRequest(v) => v6::KvRequestData::KvDeleteRequest(convert_kv_delete_request_v5_to_v6(v)?), + v5::KvRequestData::KvDeleteRangeRequest(v) => v6::KvRequestData::KvDeleteRangeRequest(convert_kv_delete_range_request_v5_to_v6(v)?), + v5::KvRequestData::KvDropRequest => v6::KvRequestData::KvDropRequest, + }) +} + +pub fn convert_kv_response_data_v5_to_v6(x: v5::KvResponseData) -> Result { + Ok(match x { + v5::KvResponseData::KvErrorResponse(v) => v6::KvResponseData::KvErrorResponse(convert_kv_error_response_v5_to_v6(v)?), + v5::KvResponseData::KvGetResponse(v) => v6::KvResponseData::KvGetResponse(convert_kv_get_response_v5_to_v6(v)?), + v5::KvResponseData::KvListResponse(v) => v6::KvResponseData::KvListResponse(convert_kv_list_response_v5_to_v6(v)?), + v5::KvResponseData::KvPutResponse => v6::KvResponseData::KvPutResponse, + v5::KvResponseData::KvDeleteResponse => v6::KvResponseData::KvDeleteResponse, + v5::KvResponseData::KvDropResponse => v6::KvResponseData::KvDropResponse, + }) +} + +pub fn convert_sqlite_dirty_page_v5_to_v6(x: v5::SqliteDirtyPage) -> Result { + Ok(v6::SqliteDirtyPage { + pgno: x.pgno, + bytes: x.bytes, + }) +} + +pub fn convert_sqlite_fetched_page_v5_to_v6(x: v5::SqliteFetchedPage) -> Result { + Ok(v6::SqliteFetchedPage { + pgno: x.pgno, + bytes: x.bytes, + }) +} + +pub fn convert_sqlite_get_pages_request_v5_to_v6(x: v5::SqliteGetPagesRequest) -> Result { + Ok(v6::SqliteGetPagesRequest { + actor_id: x.actor_id, + pgnos: x.pgnos, + expected_generation: x.expected_generation, + expected_head_txid: x.expected_head_txid, + }) +} + +pub fn convert_sqlite_get_pages_ok_v5_to_v6(x: v5::SqliteGetPagesOk) -> Result { + Ok(v6::SqliteGetPagesOk { + pages: x.pages.into_iter().map(|v| convert_sqlite_fetched_page_v5_to_v6(v)).collect::>>()?, + head_txid: x.head_txid, + }) +} + +pub fn convert_sqlite_error_response_v5_to_v6(x: v5::SqliteErrorResponse) -> Result { + Ok(v6::SqliteErrorResponse { + group: x.group, + code: x.code, + message: x.message, + }) +} + +pub fn convert_sqlite_get_pages_response_v5_to_v6(x: v5::SqliteGetPagesResponse) -> Result { + Ok(match x { + v5::SqliteGetPagesResponse::SqliteGetPagesOk(v) => v6::SqliteGetPagesResponse::SqliteGetPagesOk(convert_sqlite_get_pages_ok_v5_to_v6(v)?), + v5::SqliteGetPagesResponse::SqliteErrorResponse(v) => v6::SqliteGetPagesResponse::SqliteErrorResponse(convert_sqlite_error_response_v5_to_v6(v)?), + }) +} + +pub fn convert_sqlite_commit_request_v5_to_v6(x: v5::SqliteCommitRequest) -> Result { + Ok(v6::SqliteCommitRequest { + actor_id: x.actor_id, + dirty_pages: x.dirty_pages.into_iter().map(|v| convert_sqlite_dirty_page_v5_to_v6(v)).collect::>>()?, + db_size_pages: x.db_size_pages, + now_ms: x.now_ms, + expected_generation: x.expected_generation, + expected_head_txid: x.expected_head_txid, + }) +} + +pub fn convert_sqlite_commit_ok_v5_to_v6(x: v5::SqliteCommitOk) -> Result { + Ok(v6::SqliteCommitOk { + head_txid: x.head_txid, + }) +} + +pub fn convert_sqlite_commit_response_v5_to_v6(x: v5::SqliteCommitResponse) -> Result { + Ok(match x { + v5::SqliteCommitResponse::SqliteCommitOk(v) => v6::SqliteCommitResponse::SqliteCommitOk(convert_sqlite_commit_ok_v5_to_v6(v)?), + v5::SqliteCommitResponse::SqliteErrorResponse(v) => v6::SqliteCommitResponse::SqliteErrorResponse(convert_sqlite_error_response_v5_to_v6(v)?), + }) +} + +pub fn convert_sqlite_value_integer_v5_to_v6(x: v5::SqliteValueInteger) -> Result { + Ok(v6::SqliteValueInteger { + value: x.value, + }) +} + +pub fn convert_sqlite_value_float_v5_to_v6(x: v5::SqliteValueFloat) -> Result { + Ok(v6::SqliteValueFloat { + value: x.value, + }) +} + +pub fn convert_sqlite_value_text_v5_to_v6(x: v5::SqliteValueText) -> Result { + Ok(v6::SqliteValueText { + value: x.value, + }) +} + +pub fn convert_sqlite_value_blob_v5_to_v6(x: v5::SqliteValueBlob) -> Result { + Ok(v6::SqliteValueBlob { + value: x.value, + }) +} + +pub fn convert_sqlite_bind_param_v5_to_v6(x: v5::SqliteBindParam) -> Result { + Ok(match x { + v5::SqliteBindParam::SqliteValueNull => v6::SqliteBindParam::SqliteValueNull, + v5::SqliteBindParam::SqliteValueInteger(v) => v6::SqliteBindParam::SqliteValueInteger(convert_sqlite_value_integer_v5_to_v6(v)?), + v5::SqliteBindParam::SqliteValueFloat(v) => v6::SqliteBindParam::SqliteValueFloat(convert_sqlite_value_float_v5_to_v6(v)?), + v5::SqliteBindParam::SqliteValueText(v) => v6::SqliteBindParam::SqliteValueText(convert_sqlite_value_text_v5_to_v6(v)?), + v5::SqliteBindParam::SqliteValueBlob(v) => v6::SqliteBindParam::SqliteValueBlob(convert_sqlite_value_blob_v5_to_v6(v)?), + }) +} + +pub fn convert_sqlite_column_value_v5_to_v6(x: v5::SqliteColumnValue) -> Result { + Ok(match x { + v5::SqliteColumnValue::SqliteValueNull => v6::SqliteColumnValue::SqliteValueNull, + v5::SqliteColumnValue::SqliteValueInteger(v) => v6::SqliteColumnValue::SqliteValueInteger(convert_sqlite_value_integer_v5_to_v6(v)?), + v5::SqliteColumnValue::SqliteValueFloat(v) => v6::SqliteColumnValue::SqliteValueFloat(convert_sqlite_value_float_v5_to_v6(v)?), + v5::SqliteColumnValue::SqliteValueText(v) => v6::SqliteColumnValue::SqliteValueText(convert_sqlite_value_text_v5_to_v6(v)?), + v5::SqliteColumnValue::SqliteValueBlob(v) => v6::SqliteColumnValue::SqliteValueBlob(convert_sqlite_value_blob_v5_to_v6(v)?), + }) +} + +pub fn convert_sqlite_query_result_v5_to_v6(x: v5::SqliteQueryResult) -> Result { + Ok(v6::SqliteQueryResult { + columns: x.columns, + rows: x.rows.into_iter().map(|v| v.into_iter().map(|v| convert_sqlite_column_value_v5_to_v6(v)).collect::>>()).collect::>>()?, + }) +} + +pub fn convert_sqlite_execute_result_v5_to_v6(x: v5::SqliteExecuteResult) -> Result { + Ok(v6::SqliteExecuteResult { + columns: x.columns, + rows: x.rows.into_iter().map(|v| v.into_iter().map(|v| convert_sqlite_column_value_v5_to_v6(v)).collect::>>()).collect::>>()?, + changes: x.changes, + last_insert_row_id: x.last_insert_row_id, + }) +} + +pub fn convert_sqlite_exec_request_v5_to_v6(x: v5::SqliteExecRequest) -> Result { + Ok(v6::SqliteExecRequest { + namespace_id: x.namespace_id, + actor_id: x.actor_id, + generation: x.generation, + sql: x.sql, + }) +} + +pub fn convert_sqlite_execute_request_v5_to_v6(x: v5::SqliteExecuteRequest) -> Result { + Ok(v6::SqliteExecuteRequest { + namespace_id: x.namespace_id, + actor_id: x.actor_id, + generation: x.generation, + sql: x.sql, + params: x.params.map(|v| v.into_iter().map(|v| convert_sqlite_bind_param_v5_to_v6(v)).collect::>>()).transpose()?, + }) +} + +pub fn convert_sqlite_exec_ok_v5_to_v6(x: v5::SqliteExecOk) -> Result { + Ok(v6::SqliteExecOk { + result: convert_sqlite_query_result_v5_to_v6(x.result)?, + }) +} + +pub fn convert_sqlite_execute_ok_v5_to_v6(x: v5::SqliteExecuteOk) -> Result { + Ok(v6::SqliteExecuteOk { + result: convert_sqlite_execute_result_v5_to_v6(x.result)?, + }) +} + +pub fn convert_sqlite_exec_response_v5_to_v6(x: v5::SqliteExecResponse) -> Result { + Ok(match x { + v5::SqliteExecResponse::SqliteExecOk(v) => v6::SqliteExecResponse::SqliteExecOk(convert_sqlite_exec_ok_v5_to_v6(v)?), + v5::SqliteExecResponse::SqliteErrorResponse(v) => v6::SqliteExecResponse::SqliteErrorResponse(convert_sqlite_error_response_v5_to_v6(v)?), + }) +} + +pub fn convert_sqlite_execute_response_v5_to_v6(x: v5::SqliteExecuteResponse) -> Result { + Ok(match x { + v5::SqliteExecuteResponse::SqliteExecuteOk(v) => v6::SqliteExecuteResponse::SqliteExecuteOk(convert_sqlite_execute_ok_v5_to_v6(v)?), + v5::SqliteExecuteResponse::SqliteErrorResponse(v) => v6::SqliteExecuteResponse::SqliteErrorResponse(convert_sqlite_error_response_v5_to_v6(v)?), + }) +} + +pub fn convert_stop_code_v5_to_v6(x: v5::StopCode) -> Result { + Ok(match x { + v5::StopCode::Ok => v6::StopCode::Ok, + v5::StopCode::Error => v6::StopCode::Error, + }) +} + +pub fn convert_actor_name_v5_to_v6(x: v5::ActorName) -> Result { + Ok(v6::ActorName { + metadata: x.metadata, + }) +} + +pub fn convert_actor_config_v5_to_v6(x: v5::ActorConfig) -> Result { + Ok(v6::ActorConfig { + name: x.name, + key: x.key, + create_ts: x.create_ts, + input: x.input, + }) +} + +pub fn convert_actor_checkpoint_v5_to_v6(x: v5::ActorCheckpoint) -> Result { + Ok(v6::ActorCheckpoint { + actor_id: x.actor_id, + generation: x.generation, + index: x.index, + }) +} + +pub fn convert_actor_intent_v5_to_v6(x: v5::ActorIntent) -> Result { + Ok(match x { + v5::ActorIntent::ActorIntentSleep => v6::ActorIntent::ActorIntentSleep, + v5::ActorIntent::ActorIntentStop => v6::ActorIntent::ActorIntentStop, + }) +} + +pub fn convert_actor_state_stopped_v5_to_v6(x: v5::ActorStateStopped) -> Result { + Ok(v6::ActorStateStopped { + code: convert_stop_code_v5_to_v6(x.code)?, + message: x.message, + }) +} + +pub fn convert_actor_state_v5_to_v6(x: v5::ActorState) -> Result { + Ok(match x { + v5::ActorState::ActorStateRunning => v6::ActorState::ActorStateRunning, + v5::ActorState::ActorStateStopped(v) => v6::ActorState::ActorStateStopped(convert_actor_state_stopped_v5_to_v6(v)?), + }) +} + +pub fn convert_event_actor_intent_v5_to_v6(x: v5::EventActorIntent) -> Result { + Ok(v6::EventActorIntent { + intent: convert_actor_intent_v5_to_v6(x.intent)?, + }) +} + +pub fn convert_event_actor_state_update_v5_to_v6(x: v5::EventActorStateUpdate) -> Result { + Ok(v6::EventActorStateUpdate { + state: convert_actor_state_v5_to_v6(x.state)?, + }) +} + +pub fn convert_event_actor_set_alarm_v5_to_v6(x: v5::EventActorSetAlarm) -> Result { + Ok(v6::EventActorSetAlarm { + alarm_ts: x.alarm_ts, + }) +} + +pub fn convert_event_v5_to_v6(x: v5::Event) -> Result { + Ok(match x { + v5::Event::EventActorIntent(v) => v6::Event::EventActorIntent(convert_event_actor_intent_v5_to_v6(v)?), + v5::Event::EventActorStateUpdate(v) => v6::Event::EventActorStateUpdate(convert_event_actor_state_update_v5_to_v6(v)?), + v5::Event::EventActorSetAlarm(v) => v6::Event::EventActorSetAlarm(convert_event_actor_set_alarm_v5_to_v6(v)?), + }) +} + +pub fn convert_event_wrapper_v5_to_v6(x: v5::EventWrapper) -> Result { + Ok(v6::EventWrapper { + checkpoint: convert_actor_checkpoint_v5_to_v6(x.checkpoint)?, + inner: convert_event_v5_to_v6(x.inner)?, + }) +} + +pub fn convert_preloaded_kv_entry_v5_to_v6(x: v5::PreloadedKvEntry) -> Result { + Ok(v6::PreloadedKvEntry { + key: x.key, + value: x.value, + metadata: convert_kv_metadata_v5_to_v6(x.metadata)?, + }) +} + +pub fn convert_preloaded_kv_v5_to_v6(x: v5::PreloadedKv) -> Result { + Ok(v6::PreloadedKv { + entries: x.entries.into_iter().map(|v| convert_preloaded_kv_entry_v5_to_v6(v)).collect::>>()?, + requested_get_keys: x.requested_get_keys, + requested_prefixes: x.requested_prefixes, + }) +} + +pub fn convert_hibernating_request_v5_to_v6(x: v5::HibernatingRequest) -> Result { + Ok(v6::HibernatingRequest { + gateway_id: x.gateway_id, + request_id: x.request_id, + }) +} + +pub fn convert_command_start_actor_v5_to_v6(x: v5::CommandStartActor) -> Result { + Ok(v6::CommandStartActor { + config: convert_actor_config_v5_to_v6(x.config)?, + hibernating_requests: x.hibernating_requests.into_iter().map(|v| convert_hibernating_request_v5_to_v6(v)).collect::>>()?, + preloaded_kv: x.preloaded_kv.map(|v| convert_preloaded_kv_v5_to_v6(v)).transpose()?, + }) +} + +pub fn convert_stop_actor_reason_v5_to_v6(x: v5::StopActorReason) -> Result { + Ok(match x { + v5::StopActorReason::SleepIntent => v6::StopActorReason::SleepIntent, + v5::StopActorReason::StopIntent => v6::StopActorReason::StopIntent, + v5::StopActorReason::Destroy => v6::StopActorReason::Destroy, + v5::StopActorReason::GoingAway => v6::StopActorReason::GoingAway, + v5::StopActorReason::Lost => v6::StopActorReason::Lost, + }) +} + +pub fn convert_command_stop_actor_v5_to_v6(x: v5::CommandStopActor) -> Result { + Ok(v6::CommandStopActor { + reason: convert_stop_actor_reason_v5_to_v6(x.reason)?, + }) +} + +pub fn convert_command_v5_to_v6(x: v5::Command) -> Result { + Ok(match x { + v5::Command::CommandStartActor(v) => v6::Command::CommandStartActor(convert_command_start_actor_v5_to_v6(v)?), + v5::Command::CommandStopActor(v) => v6::Command::CommandStopActor(convert_command_stop_actor_v5_to_v6(v)?), + }) +} + +pub fn convert_command_wrapper_v5_to_v6(x: v5::CommandWrapper) -> Result { + Ok(v6::CommandWrapper { + checkpoint: convert_actor_checkpoint_v5_to_v6(x.checkpoint)?, + inner: convert_command_v5_to_v6(x.inner)?, + }) +} + +pub fn convert_actor_command_key_data_v5_to_v6(x: v5::ActorCommandKeyData) -> Result { + Ok(match x { + v5::ActorCommandKeyData::CommandStartActor(v) => v6::ActorCommandKeyData::CommandStartActor(convert_command_start_actor_v5_to_v6(v)?), + v5::ActorCommandKeyData::CommandStopActor(v) => v6::ActorCommandKeyData::CommandStopActor(convert_command_stop_actor_v5_to_v6(v)?), + }) +} + +pub fn convert_message_id_v5_to_v6(x: v5::MessageId) -> Result { + Ok(v6::MessageId { + gateway_id: x.gateway_id, + request_id: x.request_id, + message_index: x.message_index, + }) +} + +pub fn convert_to_envoy_request_start_v5_to_v6(x: v5::ToEnvoyRequestStart) -> Result { + Ok(v6::ToEnvoyRequestStart { + actor_id: x.actor_id, + method: x.method, + path: x.path, + headers: x.headers, + body: x.body, + stream: x.stream, + }) +} + +pub fn convert_to_envoy_request_chunk_v5_to_v6(x: v5::ToEnvoyRequestChunk) -> Result { + Ok(v6::ToEnvoyRequestChunk { + body: x.body, + finish: x.finish, + }) +} + +pub fn convert_to_rivet_response_start_v5_to_v6(x: v5::ToRivetResponseStart) -> Result { + Ok(v6::ToRivetResponseStart { + status: x.status, + headers: x.headers, + body: x.body, + stream: x.stream, + }) +} + +pub fn convert_to_rivet_response_chunk_v5_to_v6(x: v5::ToRivetResponseChunk) -> Result { + Ok(v6::ToRivetResponseChunk { + body: x.body, + finish: x.finish, + }) +} + +pub fn convert_to_envoy_web_socket_open_v5_to_v6(x: v5::ToEnvoyWebSocketOpen) -> Result { + Ok(v6::ToEnvoyWebSocketOpen { + actor_id: x.actor_id, + path: x.path, + headers: x.headers, + }) +} + +pub fn convert_to_envoy_web_socket_message_v5_to_v6(x: v5::ToEnvoyWebSocketMessage) -> Result { + Ok(v6::ToEnvoyWebSocketMessage { + data: x.data, + binary: x.binary, + }) +} + +pub fn convert_to_envoy_web_socket_close_v5_to_v6(x: v5::ToEnvoyWebSocketClose) -> Result { + Ok(v6::ToEnvoyWebSocketClose { + code: x.code, + reason: x.reason, + }) +} + +pub fn convert_to_rivet_web_socket_open_v5_to_v6(x: v5::ToRivetWebSocketOpen) -> Result { + Ok(v6::ToRivetWebSocketOpen { + can_hibernate: x.can_hibernate, + }) +} + +pub fn convert_to_rivet_web_socket_message_v5_to_v6(x: v5::ToRivetWebSocketMessage) -> Result { + Ok(v6::ToRivetWebSocketMessage { + data: x.data, + binary: x.binary, + }) +} + +pub fn convert_to_rivet_web_socket_message_ack_v5_to_v6(x: v5::ToRivetWebSocketMessageAck) -> Result { + Ok(v6::ToRivetWebSocketMessageAck { + index: x.index, + }) +} + +pub fn convert_to_rivet_web_socket_close_v5_to_v6(x: v5::ToRivetWebSocketClose) -> Result { + Ok(v6::ToRivetWebSocketClose { + code: x.code, + reason: x.reason, + hibernate: x.hibernate, + }) +} + +pub fn convert_to_rivet_tunnel_message_kind_v5_to_v6(x: v5::ToRivetTunnelMessageKind) -> Result { + Ok(match x { + v5::ToRivetTunnelMessageKind::ToRivetResponseStart(v) => v6::ToRivetTunnelMessageKind::ToRivetResponseStart(convert_to_rivet_response_start_v5_to_v6(v)?), + v5::ToRivetTunnelMessageKind::ToRivetResponseChunk(v) => v6::ToRivetTunnelMessageKind::ToRivetResponseChunk(convert_to_rivet_response_chunk_v5_to_v6(v)?), + v5::ToRivetTunnelMessageKind::ToRivetResponseAbort => v6::ToRivetTunnelMessageKind::ToRivetResponseAbort, + v5::ToRivetTunnelMessageKind::ToRivetWebSocketOpen(v) => v6::ToRivetTunnelMessageKind::ToRivetWebSocketOpen(convert_to_rivet_web_socket_open_v5_to_v6(v)?), + v5::ToRivetTunnelMessageKind::ToRivetWebSocketMessage(v) => v6::ToRivetTunnelMessageKind::ToRivetWebSocketMessage(convert_to_rivet_web_socket_message_v5_to_v6(v)?), + v5::ToRivetTunnelMessageKind::ToRivetWebSocketMessageAck(v) => v6::ToRivetTunnelMessageKind::ToRivetWebSocketMessageAck(convert_to_rivet_web_socket_message_ack_v5_to_v6(v)?), + v5::ToRivetTunnelMessageKind::ToRivetWebSocketClose(v) => v6::ToRivetTunnelMessageKind::ToRivetWebSocketClose(convert_to_rivet_web_socket_close_v5_to_v6(v)?), + }) +} + +pub fn convert_to_rivet_tunnel_message_v5_to_v6(x: v5::ToRivetTunnelMessage) -> Result { + Ok(v6::ToRivetTunnelMessage { + message_id: convert_message_id_v5_to_v6(x.message_id)?, + message_kind: convert_to_rivet_tunnel_message_kind_v5_to_v6(x.message_kind)?, + }) +} + +pub fn convert_to_envoy_tunnel_message_kind_v5_to_v6(x: v5::ToEnvoyTunnelMessageKind) -> Result { + Ok(match x { + v5::ToEnvoyTunnelMessageKind::ToEnvoyRequestStart(v) => v6::ToEnvoyTunnelMessageKind::ToEnvoyRequestStart(convert_to_envoy_request_start_v5_to_v6(v)?), + v5::ToEnvoyTunnelMessageKind::ToEnvoyRequestChunk(v) => v6::ToEnvoyTunnelMessageKind::ToEnvoyRequestChunk(convert_to_envoy_request_chunk_v5_to_v6(v)?), + v5::ToEnvoyTunnelMessageKind::ToEnvoyRequestAbort => v6::ToEnvoyTunnelMessageKind::ToEnvoyRequestAbort, + v5::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketOpen(v) => v6::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketOpen(convert_to_envoy_web_socket_open_v5_to_v6(v)?), + v5::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketMessage(v) => v6::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketMessage(convert_to_envoy_web_socket_message_v5_to_v6(v)?), + v5::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketClose(v) => v6::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketClose(convert_to_envoy_web_socket_close_v5_to_v6(v)?), + }) +} + +pub fn convert_to_envoy_tunnel_message_v5_to_v6(x: v5::ToEnvoyTunnelMessage) -> Result { + Ok(v6::ToEnvoyTunnelMessage { + message_id: convert_message_id_v5_to_v6(x.message_id)?, + message_kind: convert_to_envoy_tunnel_message_kind_v5_to_v6(x.message_kind)?, + }) +} + +pub fn convert_to_envoy_ping_v5_to_v6(x: v5::ToEnvoyPing) -> Result { + Ok(v6::ToEnvoyPing { + ts: x.ts, + }) +} + +pub fn convert_to_rivet_metadata_v5_to_v6(x: v5::ToRivetMetadata) -> Result { + Ok(v6::ToRivetMetadata { + prepopulate_actor_names: x.prepopulate_actor_names.map(|v| v.into_iter().map(|(k, v)| -> Result<_> { Ok((k, convert_actor_name_v5_to_v6(v)?)) }).collect::>()).transpose()?, + metadata: x.metadata, + }) +} + +pub fn convert_to_rivet_events_v5_to_v6(x: v5::ToRivetEvents) -> Result { + Ok(x.into_iter().map(|v| convert_event_wrapper_v5_to_v6(v)).collect::>>()?) +} + +pub fn convert_to_rivet_ack_commands_v5_to_v6(x: v5::ToRivetAckCommands) -> Result { + Ok(v6::ToRivetAckCommands { + last_command_checkpoints: x.last_command_checkpoints.into_iter().map(|v| convert_actor_checkpoint_v5_to_v6(v)).collect::>>()?, + }) +} + +pub fn convert_to_rivet_pong_v5_to_v6(x: v5::ToRivetPong) -> Result { + Ok(v6::ToRivetPong { + ts: x.ts, + }) +} + +pub fn convert_to_rivet_kv_request_v5_to_v6(x: v5::ToRivetKvRequest) -> Result { + Ok(v6::ToRivetKvRequest { + actor_id: x.actor_id, + request_id: x.request_id, + data: convert_kv_request_data_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_rivet_sqlite_get_pages_request_v5_to_v6(x: v5::ToRivetSqliteGetPagesRequest) -> Result { + Ok(v6::ToRivetSqliteGetPagesRequest { + request_id: x.request_id, + data: convert_sqlite_get_pages_request_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_rivet_sqlite_commit_request_v5_to_v6(x: v5::ToRivetSqliteCommitRequest) -> Result { + Ok(v6::ToRivetSqliteCommitRequest { + request_id: x.request_id, + data: convert_sqlite_commit_request_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_rivet_sqlite_exec_request_v5_to_v6(x: v5::ToRivetSqliteExecRequest) -> Result { + Ok(v6::ToRivetSqliteExecRequest { + request_id: x.request_id, + data: convert_sqlite_exec_request_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_rivet_sqlite_execute_request_v5_to_v6(x: v5::ToRivetSqliteExecuteRequest) -> Result { + Ok(v6::ToRivetSqliteExecuteRequest { + request_id: x.request_id, + data: convert_sqlite_execute_request_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_rivet_v5_to_v6(x: v5::ToRivet) -> Result { + Ok(match x { + v5::ToRivet::ToRivetMetadata(v) => v6::ToRivet::ToRivetMetadata(convert_to_rivet_metadata_v5_to_v6(v)?), + v5::ToRivet::ToRivetEvents(v) => v6::ToRivet::ToRivetEvents(convert_to_rivet_events_v5_to_v6(v)?), + v5::ToRivet::ToRivetAckCommands(v) => v6::ToRivet::ToRivetAckCommands(convert_to_rivet_ack_commands_v5_to_v6(v)?), + v5::ToRivet::ToRivetStopping => v6::ToRivet::ToRivetStopping, + v5::ToRivet::ToRivetPong(v) => v6::ToRivet::ToRivetPong(convert_to_rivet_pong_v5_to_v6(v)?), + v5::ToRivet::ToRivetKvRequest(v) => v6::ToRivet::ToRivetKvRequest(convert_to_rivet_kv_request_v5_to_v6(v)?), + v5::ToRivet::ToRivetTunnelMessage(v) => v6::ToRivet::ToRivetTunnelMessage(convert_to_rivet_tunnel_message_v5_to_v6(v)?), + v5::ToRivet::ToRivetSqliteGetPagesRequest(v) => v6::ToRivet::ToRivetSqliteGetPagesRequest(convert_to_rivet_sqlite_get_pages_request_v5_to_v6(v)?), + v5::ToRivet::ToRivetSqliteCommitRequest(v) => v6::ToRivet::ToRivetSqliteCommitRequest(convert_to_rivet_sqlite_commit_request_v5_to_v6(v)?), + v5::ToRivet::ToRivetSqliteExecRequest(v) => v6::ToRivet::ToRivetSqliteExecRequest(convert_to_rivet_sqlite_exec_request_v5_to_v6(v)?), + v5::ToRivet::ToRivetSqliteExecuteRequest(v) => v6::ToRivet::ToRivetSqliteExecuteRequest(convert_to_rivet_sqlite_execute_request_v5_to_v6(v)?), + }) +} + +pub fn convert_protocol_metadata_v5_to_v6(x: v5::ProtocolMetadata) -> Result { + Ok(v6::ProtocolMetadata { + envoy_lost_threshold: x.envoy_lost_threshold, + actor_stop_threshold: x.actor_stop_threshold, + max_response_payload_size: x.max_response_payload_size, + }) +} + +pub fn convert_to_envoy_init_v5_to_v6(x: v5::ToEnvoyInit) -> Result { + Ok(v6::ToEnvoyInit { + metadata: convert_protocol_metadata_v5_to_v6(x.metadata)?, + }) +} + +pub fn convert_to_envoy_commands_v5_to_v6(x: v5::ToEnvoyCommands) -> Result { + Ok(x.into_iter().map(|v| convert_command_wrapper_v5_to_v6(v)).collect::>>()?) +} + +pub fn convert_to_envoy_ack_events_v5_to_v6(x: v5::ToEnvoyAckEvents) -> Result { + Ok(v6::ToEnvoyAckEvents { + last_event_checkpoints: x.last_event_checkpoints.into_iter().map(|v| convert_actor_checkpoint_v5_to_v6(v)).collect::>>()?, + }) +} + +pub fn convert_to_envoy_kv_response_v5_to_v6(x: v5::ToEnvoyKvResponse) -> Result { + Ok(v6::ToEnvoyKvResponse { + request_id: x.request_id, + data: convert_kv_response_data_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_envoy_sqlite_get_pages_response_v5_to_v6(x: v5::ToEnvoySqliteGetPagesResponse) -> Result { + Ok(v6::ToEnvoySqliteGetPagesResponse { + request_id: x.request_id, + data: convert_sqlite_get_pages_response_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_envoy_sqlite_commit_response_v5_to_v6(x: v5::ToEnvoySqliteCommitResponse) -> Result { + Ok(v6::ToEnvoySqliteCommitResponse { + request_id: x.request_id, + data: convert_sqlite_commit_response_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_envoy_sqlite_exec_response_v5_to_v6(x: v5::ToEnvoySqliteExecResponse) -> Result { + Ok(v6::ToEnvoySqliteExecResponse { + request_id: x.request_id, + data: convert_sqlite_exec_response_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_envoy_sqlite_execute_response_v5_to_v6(x: v5::ToEnvoySqliteExecuteResponse) -> Result { + Ok(v6::ToEnvoySqliteExecuteResponse { + request_id: x.request_id, + data: convert_sqlite_execute_response_v5_to_v6(x.data)?, + }) +} + +pub fn convert_to_envoy_v5_to_v6(x: v5::ToEnvoy) -> Result { + Ok(match x { + v5::ToEnvoy::ToEnvoyInit(v) => v6::ToEnvoy::ToEnvoyInit(convert_to_envoy_init_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoyCommands(v) => v6::ToEnvoy::ToEnvoyCommands(convert_to_envoy_commands_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoyAckEvents(v) => v6::ToEnvoy::ToEnvoyAckEvents(convert_to_envoy_ack_events_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoyKvResponse(v) => v6::ToEnvoy::ToEnvoyKvResponse(convert_to_envoy_kv_response_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoyTunnelMessage(v) => v6::ToEnvoy::ToEnvoyTunnelMessage(convert_to_envoy_tunnel_message_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoyPing(v) => v6::ToEnvoy::ToEnvoyPing(convert_to_envoy_ping_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoySqliteGetPagesResponse(v) => v6::ToEnvoy::ToEnvoySqliteGetPagesResponse(convert_to_envoy_sqlite_get_pages_response_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoySqliteCommitResponse(v) => v6::ToEnvoy::ToEnvoySqliteCommitResponse(convert_to_envoy_sqlite_commit_response_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoySqliteExecResponse(v) => v6::ToEnvoy::ToEnvoySqliteExecResponse(convert_to_envoy_sqlite_exec_response_v5_to_v6(v)?), + v5::ToEnvoy::ToEnvoySqliteExecuteResponse(v) => v6::ToEnvoy::ToEnvoySqliteExecuteResponse(convert_to_envoy_sqlite_execute_response_v5_to_v6(v)?), + }) +} + +pub fn convert_to_envoy_conn_ping_v5_to_v6(x: v5::ToEnvoyConnPing) -> Result { + Ok(v6::ToEnvoyConnPing { + gateway_id: x.gateway_id, + request_id: x.request_id, + ts: x.ts, + }) +} + +pub fn convert_to_envoy_conn_v5_to_v6(x: v5::ToEnvoyConn) -> Result { + Ok(match x { + v5::ToEnvoyConn::ToEnvoyConnPing(v) => v6::ToEnvoyConn::ToEnvoyConnPing(convert_to_envoy_conn_ping_v5_to_v6(v)?), + v5::ToEnvoyConn::ToEnvoyConnClose => v6::ToEnvoyConn::ToEnvoyConnClose, + v5::ToEnvoyConn::ToEnvoyCommands(v) => v6::ToEnvoyConn::ToEnvoyCommands(convert_to_envoy_commands_v5_to_v6(v)?), + v5::ToEnvoyConn::ToEnvoyAckEvents(v) => v6::ToEnvoyConn::ToEnvoyAckEvents(convert_to_envoy_ack_events_v5_to_v6(v)?), + v5::ToEnvoyConn::ToEnvoyTunnelMessage(v) => v6::ToEnvoyConn::ToEnvoyTunnelMessage(convert_to_envoy_tunnel_message_v5_to_v6(v)?), + }) +} + +pub fn convert_to_gateway_pong_v5_to_v6(x: v5::ToGatewayPong) -> Result { + Ok(v6::ToGatewayPong { + request_id: x.request_id, + ts: x.ts, + }) +} + +pub fn convert_to_gateway_v5_to_v6(x: v5::ToGateway) -> Result { + Ok(match x { + v5::ToGateway::ToGatewayPong(v) => v6::ToGateway::ToGatewayPong(convert_to_gateway_pong_v5_to_v6(v)?), + v5::ToGateway::ToRivetTunnelMessage(v) => v6::ToGateway::ToRivetTunnelMessage(convert_to_rivet_tunnel_message_v5_to_v6(v)?), + }) +} + +pub fn convert_to_outbound_actor_start_v5_to_v6(x: v5::ToOutboundActorStart) -> Result { + Ok(v6::ToOutboundActorStart { + namespace_id: x.namespace_id, + pool_name: x.pool_name, + checkpoint: convert_actor_checkpoint_v5_to_v6(x.checkpoint)?, + actor_config: convert_actor_config_v5_to_v6(x.actor_config)?, + }) +} + +pub fn convert_to_outbound_v5_to_v6(x: v5::ToOutbound) -> Result { + Ok(match x { + v5::ToOutbound::ToOutboundActorStart(v) => v6::ToOutbound::ToOutboundActorStart(convert_to_outbound_actor_start_v5_to_v6(v)?), + }) +} diff --git a/engine/sdks/rust/envoy-protocol/src/versioned/v6_to_v5.rs b/engine/sdks/rust/envoy-protocol/src/versioned/v6_to_v5.rs new file mode 100644 index 0000000000..0581569ddb --- /dev/null +++ b/engine/sdks/rust/envoy-protocol/src/versioned/v6_to_v5.rs @@ -0,0 +1,789 @@ +// from: v6.bare, to: v5.bare + +#![allow(dead_code, unused_variables)] + +use anyhow::Result; + +use crate::generated::{v6, v5}; +use super::{ + ProtocolCompatibilityDirection, ProtocolCompatibilityFeature, incompatible, +}; + +pub fn convert_kv_metadata_v6_to_v5(x: v6::KvMetadata) -> Result { + Ok(v5::KvMetadata { + version: x.version, + update_ts: x.update_ts, + }) +} + +pub fn convert_kv_list_range_query_v6_to_v5(x: v6::KvListRangeQuery) -> Result { + Ok(v5::KvListRangeQuery { + start: x.start, + end: x.end, + exclusive: x.exclusive, + }) +} + +pub fn convert_kv_list_prefix_query_v6_to_v5(x: v6::KvListPrefixQuery) -> Result { + Ok(v5::KvListPrefixQuery { + key: x.key, + }) +} + +pub fn convert_kv_list_query_v6_to_v5(x: v6::KvListQuery) -> Result { + Ok(match x { + v6::KvListQuery::KvListAllQuery => v5::KvListQuery::KvListAllQuery, + v6::KvListQuery::KvListRangeQuery(v) => v5::KvListQuery::KvListRangeQuery(convert_kv_list_range_query_v6_to_v5(v)?), + v6::KvListQuery::KvListPrefixQuery(v) => v5::KvListQuery::KvListPrefixQuery(convert_kv_list_prefix_query_v6_to_v5(v)?), + }) +} + +pub fn convert_kv_get_request_v6_to_v5(x: v6::KvGetRequest) -> Result { + Ok(v5::KvGetRequest { + keys: x.keys, + }) +} + +pub fn convert_kv_list_request_v6_to_v5(x: v6::KvListRequest) -> Result { + Ok(v5::KvListRequest { + query: convert_kv_list_query_v6_to_v5(x.query)?, + reverse: x.reverse, + limit: x.limit, + }) +} + +pub fn convert_kv_put_request_v6_to_v5(x: v6::KvPutRequest) -> Result { + Ok(v5::KvPutRequest { + keys: x.keys, + values: x.values, + }) +} + +pub fn convert_kv_delete_request_v6_to_v5(x: v6::KvDeleteRequest) -> Result { + Ok(v5::KvDeleteRequest { + keys: x.keys, + }) +} + +pub fn convert_kv_delete_range_request_v6_to_v5(x: v6::KvDeleteRangeRequest) -> Result { + Ok(v5::KvDeleteRangeRequest { + start: x.start, + end: x.end, + }) +} + +pub fn convert_kv_error_response_v6_to_v5(x: v6::KvErrorResponse) -> Result { + Ok(v5::KvErrorResponse { + message: x.message, + }) +} + +pub fn convert_kv_get_response_v6_to_v5(x: v6::KvGetResponse) -> Result { + Ok(v5::KvGetResponse { + keys: x.keys, + values: x.values, + metadata: x.metadata.into_iter().map(|v| convert_kv_metadata_v6_to_v5(v)).collect::>>()?, + }) +} + +pub fn convert_kv_list_response_v6_to_v5(x: v6::KvListResponse) -> Result { + Ok(v5::KvListResponse { + keys: x.keys, + values: x.values, + metadata: x.metadata.into_iter().map(|v| convert_kv_metadata_v6_to_v5(v)).collect::>>()?, + }) +} + +pub fn convert_kv_request_data_v6_to_v5(x: v6::KvRequestData) -> Result { + Ok(match x { + v6::KvRequestData::KvGetRequest(v) => v5::KvRequestData::KvGetRequest(convert_kv_get_request_v6_to_v5(v)?), + v6::KvRequestData::KvListRequest(v) => v5::KvRequestData::KvListRequest(convert_kv_list_request_v6_to_v5(v)?), + v6::KvRequestData::KvPutRequest(v) => v5::KvRequestData::KvPutRequest(convert_kv_put_request_v6_to_v5(v)?), + v6::KvRequestData::KvDeleteRequest(v) => v5::KvRequestData::KvDeleteRequest(convert_kv_delete_request_v6_to_v5(v)?), + v6::KvRequestData::KvDeleteRangeRequest(v) => v5::KvRequestData::KvDeleteRangeRequest(convert_kv_delete_range_request_v6_to_v5(v)?), + v6::KvRequestData::KvDropRequest => v5::KvRequestData::KvDropRequest, + }) +} + +pub fn convert_kv_response_data_v6_to_v5(x: v6::KvResponseData) -> Result { + Ok(match x { + v6::KvResponseData::KvErrorResponse(v) => v5::KvResponseData::KvErrorResponse(convert_kv_error_response_v6_to_v5(v)?), + v6::KvResponseData::KvGetResponse(v) => v5::KvResponseData::KvGetResponse(convert_kv_get_response_v6_to_v5(v)?), + v6::KvResponseData::KvListResponse(v) => v5::KvResponseData::KvListResponse(convert_kv_list_response_v6_to_v5(v)?), + v6::KvResponseData::KvPutResponse => v5::KvResponseData::KvPutResponse, + v6::KvResponseData::KvDeleteResponse => v5::KvResponseData::KvDeleteResponse, + v6::KvResponseData::KvDropResponse => v5::KvResponseData::KvDropResponse, + }) +} + +pub fn convert_sqlite_dirty_page_v6_to_v5(x: v6::SqliteDirtyPage) -> Result { + Ok(v5::SqliteDirtyPage { + pgno: x.pgno, + bytes: x.bytes, + }) +} + +pub fn convert_sqlite_fetched_page_v6_to_v5(x: v6::SqliteFetchedPage) -> Result { + Ok(v5::SqliteFetchedPage { + pgno: x.pgno, + bytes: x.bytes, + }) +} + +pub fn convert_sqlite_get_pages_request_v6_to_v5(x: v6::SqliteGetPagesRequest) -> Result { + Ok(v5::SqliteGetPagesRequest { + actor_id: x.actor_id, + pgnos: x.pgnos, + expected_generation: x.expected_generation, + expected_head_txid: x.expected_head_txid, + }) +} + +pub fn convert_sqlite_get_pages_ok_v6_to_v5(x: v6::SqliteGetPagesOk) -> Result { + Ok(v5::SqliteGetPagesOk { + pages: x.pages.into_iter().map(|v| convert_sqlite_fetched_page_v6_to_v5(v)).collect::>>()?, + head_txid: x.head_txid, + }) +} + +pub fn convert_sqlite_error_response_v6_to_v5(x: v6::SqliteErrorResponse) -> Result { + Ok(v5::SqliteErrorResponse { + group: x.group, + code: x.code, + message: x.message, + }) +} + +pub fn convert_sqlite_get_pages_response_v6_to_v5(x: v6::SqliteGetPagesResponse) -> Result { + Ok(match x { + v6::SqliteGetPagesResponse::SqliteGetPagesOk(v) => v5::SqliteGetPagesResponse::SqliteGetPagesOk(convert_sqlite_get_pages_ok_v6_to_v5(v)?), + v6::SqliteGetPagesResponse::SqliteErrorResponse(v) => v5::SqliteGetPagesResponse::SqliteErrorResponse(convert_sqlite_error_response_v6_to_v5(v)?), + }) +} + +pub fn convert_sqlite_commit_request_v6_to_v5(x: v6::SqliteCommitRequest) -> Result { + Ok(v5::SqliteCommitRequest { + actor_id: x.actor_id, + dirty_pages: x.dirty_pages.into_iter().map(|v| convert_sqlite_dirty_page_v6_to_v5(v)).collect::>>()?, + db_size_pages: x.db_size_pages, + now_ms: x.now_ms, + expected_generation: x.expected_generation, + expected_head_txid: x.expected_head_txid, + }) +} + +pub fn convert_sqlite_commit_ok_v6_to_v5(x: v6::SqliteCommitOk) -> Result { + Ok(v5::SqliteCommitOk { + head_txid: x.head_txid, + }) +} + +pub fn convert_sqlite_commit_response_v6_to_v5(x: v6::SqliteCommitResponse) -> Result { + Ok(match x { + v6::SqliteCommitResponse::SqliteCommitOk(v) => v5::SqliteCommitResponse::SqliteCommitOk(convert_sqlite_commit_ok_v6_to_v5(v)?), + v6::SqliteCommitResponse::SqliteErrorResponse(v) => v5::SqliteCommitResponse::SqliteErrorResponse(convert_sqlite_error_response_v6_to_v5(v)?), + }) +} + +pub fn convert_sqlite_value_integer_v6_to_v5(x: v6::SqliteValueInteger) -> Result { + Ok(v5::SqliteValueInteger { + value: x.value, + }) +} + +pub fn convert_sqlite_value_float_v6_to_v5(x: v6::SqliteValueFloat) -> Result { + Ok(v5::SqliteValueFloat { + value: x.value, + }) +} + +pub fn convert_sqlite_value_text_v6_to_v5(x: v6::SqliteValueText) -> Result { + Ok(v5::SqliteValueText { + value: x.value, + }) +} + +pub fn convert_sqlite_value_blob_v6_to_v5(x: v6::SqliteValueBlob) -> Result { + Ok(v5::SqliteValueBlob { + value: x.value, + }) +} + +pub fn convert_sqlite_bind_param_v6_to_v5(x: v6::SqliteBindParam) -> Result { + Ok(match x { + v6::SqliteBindParam::SqliteValueNull => v5::SqliteBindParam::SqliteValueNull, + v6::SqliteBindParam::SqliteValueInteger(v) => v5::SqliteBindParam::SqliteValueInteger(convert_sqlite_value_integer_v6_to_v5(v)?), + v6::SqliteBindParam::SqliteValueFloat(v) => v5::SqliteBindParam::SqliteValueFloat(convert_sqlite_value_float_v6_to_v5(v)?), + v6::SqliteBindParam::SqliteValueText(v) => v5::SqliteBindParam::SqliteValueText(convert_sqlite_value_text_v6_to_v5(v)?), + v6::SqliteBindParam::SqliteValueBlob(v) => v5::SqliteBindParam::SqliteValueBlob(convert_sqlite_value_blob_v6_to_v5(v)?), + }) +} + +pub fn convert_sqlite_column_value_v6_to_v5(x: v6::SqliteColumnValue) -> Result { + Ok(match x { + v6::SqliteColumnValue::SqliteValueNull => v5::SqliteColumnValue::SqliteValueNull, + v6::SqliteColumnValue::SqliteValueInteger(v) => v5::SqliteColumnValue::SqliteValueInteger(convert_sqlite_value_integer_v6_to_v5(v)?), + v6::SqliteColumnValue::SqliteValueFloat(v) => v5::SqliteColumnValue::SqliteValueFloat(convert_sqlite_value_float_v6_to_v5(v)?), + v6::SqliteColumnValue::SqliteValueText(v) => v5::SqliteColumnValue::SqliteValueText(convert_sqlite_value_text_v6_to_v5(v)?), + v6::SqliteColumnValue::SqliteValueBlob(v) => v5::SqliteColumnValue::SqliteValueBlob(convert_sqlite_value_blob_v6_to_v5(v)?), + }) +} + +pub fn convert_sqlite_query_result_v6_to_v5(x: v6::SqliteQueryResult) -> Result { + Ok(v5::SqliteQueryResult { + columns: x.columns, + rows: x.rows.into_iter().map(|v| v.into_iter().map(|v| convert_sqlite_column_value_v6_to_v5(v)).collect::>>()).collect::>>()?, + }) +} + +pub fn convert_sqlite_execute_result_v6_to_v5(x: v6::SqliteExecuteResult) -> Result { + Ok(v5::SqliteExecuteResult { + columns: x.columns, + rows: x.rows.into_iter().map(|v| v.into_iter().map(|v| convert_sqlite_column_value_v6_to_v5(v)).collect::>>()).collect::>>()?, + changes: x.changes, + last_insert_row_id: x.last_insert_row_id, + }) +} + +pub fn convert_sqlite_exec_request_v6_to_v5(x: v6::SqliteExecRequest) -> Result { + Ok(v5::SqliteExecRequest { + namespace_id: x.namespace_id, + actor_id: x.actor_id, + generation: x.generation, + sql: x.sql, + }) +} + +pub fn convert_sqlite_execute_request_v6_to_v5(x: v6::SqliteExecuteRequest) -> Result { + Ok(v5::SqliteExecuteRequest { + namespace_id: x.namespace_id, + actor_id: x.actor_id, + generation: x.generation, + sql: x.sql, + params: x.params.map(|v| v.into_iter().map(|v| convert_sqlite_bind_param_v6_to_v5(v)).collect::>>()).transpose()?, + }) +} + +pub fn convert_sqlite_exec_ok_v6_to_v5(x: v6::SqliteExecOk) -> Result { + Ok(v5::SqliteExecOk { + result: convert_sqlite_query_result_v6_to_v5(x.result)?, + }) +} + +pub fn convert_sqlite_execute_ok_v6_to_v5(x: v6::SqliteExecuteOk) -> Result { + Ok(v5::SqliteExecuteOk { + result: convert_sqlite_execute_result_v6_to_v5(x.result)?, + }) +} + +pub fn convert_sqlite_exec_response_v6_to_v5(x: v6::SqliteExecResponse) -> Result { + Ok(match x { + v6::SqliteExecResponse::SqliteExecOk(v) => v5::SqliteExecResponse::SqliteExecOk(convert_sqlite_exec_ok_v6_to_v5(v)?), + v6::SqliteExecResponse::SqliteErrorResponse(v) => v5::SqliteExecResponse::SqliteErrorResponse(convert_sqlite_error_response_v6_to_v5(v)?), + }) +} + +pub fn convert_sqlite_execute_response_v6_to_v5(x: v6::SqliteExecuteResponse) -> Result { + Ok(match x { + v6::SqliteExecuteResponse::SqliteExecuteOk(v) => v5::SqliteExecuteResponse::SqliteExecuteOk(convert_sqlite_execute_ok_v6_to_v5(v)?), + v6::SqliteExecuteResponse::SqliteErrorResponse(v) => v5::SqliteExecuteResponse::SqliteErrorResponse(convert_sqlite_error_response_v6_to_v5(v)?), + }) +} + +pub fn convert_stop_code_v6_to_v5(x: v6::StopCode) -> Result { + Ok(match x { + v6::StopCode::Ok => v5::StopCode::Ok, + v6::StopCode::Error => v5::StopCode::Error, + }) +} + +pub fn convert_actor_name_v6_to_v5(x: v6::ActorName) -> Result { + Ok(v5::ActorName { + metadata: x.metadata, + }) +} + +pub fn convert_actor_config_v6_to_v5(x: v6::ActorConfig) -> Result { + Ok(v5::ActorConfig { + name: x.name, + key: x.key, + create_ts: x.create_ts, + input: x.input, + }) +} + +pub fn convert_actor_checkpoint_v6_to_v5(x: v6::ActorCheckpoint) -> Result { + Ok(v5::ActorCheckpoint { + actor_id: x.actor_id, + generation: x.generation, + index: x.index, + }) +} + +pub fn convert_actor_intent_v6_to_v5(x: v6::ActorIntent) -> Result { + Ok(match x { + v6::ActorIntent::ActorIntentSleep => v5::ActorIntent::ActorIntentSleep, + v6::ActorIntent::ActorIntentStop => v5::ActorIntent::ActorIntentStop, + }) +} + +pub fn convert_actor_state_stopped_v6_to_v5(x: v6::ActorStateStopped) -> Result { + Ok(v5::ActorStateStopped { + code: convert_stop_code_v6_to_v5(x.code)?, + message: x.message, + }) +} + +pub fn convert_actor_state_v6_to_v5(x: v6::ActorState) -> Result { + Ok(match x { + v6::ActorState::ActorStateRunning => v5::ActorState::ActorStateRunning, + v6::ActorState::ActorStateStopped(v) => v5::ActorState::ActorStateStopped(convert_actor_state_stopped_v6_to_v5(v)?), + }) +} + +pub fn convert_event_actor_intent_v6_to_v5(x: v6::EventActorIntent) -> Result { + Ok(v5::EventActorIntent { + intent: convert_actor_intent_v6_to_v5(x.intent)?, + }) +} + +pub fn convert_event_actor_state_update_v6_to_v5(x: v6::EventActorStateUpdate) -> Result { + Ok(v5::EventActorStateUpdate { + state: convert_actor_state_v6_to_v5(x.state)?, + }) +} + +pub fn convert_event_actor_set_alarm_v6_to_v5(x: v6::EventActorSetAlarm) -> Result { + Ok(v5::EventActorSetAlarm { + alarm_ts: x.alarm_ts, + }) +} + +pub fn convert_event_v6_to_v5(x: v6::Event) -> Result { + Ok(match x { + v6::Event::EventActorIntent(v) => v5::Event::EventActorIntent(convert_event_actor_intent_v6_to_v5(v)?), + v6::Event::EventActorStateUpdate(v) => v5::Event::EventActorStateUpdate(convert_event_actor_state_update_v6_to_v5(v)?), + v6::Event::EventActorSetAlarm(v) => v5::Event::EventActorSetAlarm(convert_event_actor_set_alarm_v6_to_v5(v)?), + }) +} + +pub fn convert_event_wrapper_v6_to_v5(x: v6::EventWrapper) -> Result { + Ok(v5::EventWrapper { + checkpoint: convert_actor_checkpoint_v6_to_v5(x.checkpoint)?, + inner: convert_event_v6_to_v5(x.inner)?, + }) +} + +pub fn convert_preloaded_kv_entry_v6_to_v5(x: v6::PreloadedKvEntry) -> Result { + Ok(v5::PreloadedKvEntry { + key: x.key, + value: x.value, + metadata: convert_kv_metadata_v6_to_v5(x.metadata)?, + }) +} + +pub fn convert_preloaded_kv_v6_to_v5(x: v6::PreloadedKv) -> Result { + Ok(v5::PreloadedKv { + entries: x.entries.into_iter().map(|v| convert_preloaded_kv_entry_v6_to_v5(v)).collect::>>()?, + requested_get_keys: x.requested_get_keys, + requested_prefixes: x.requested_prefixes, + }) +} + +pub fn convert_hibernating_request_v6_to_v5(x: v6::HibernatingRequest) -> Result { + Ok(v5::HibernatingRequest { + gateway_id: x.gateway_id, + request_id: x.request_id, + }) +} + +pub fn convert_command_start_actor_v6_to_v5(x: v6::CommandStartActor) -> Result { + Ok(v5::CommandStartActor { + config: convert_actor_config_v6_to_v5(x.config)?, + hibernating_requests: x.hibernating_requests.into_iter().map(|v| convert_hibernating_request_v6_to_v5(v)).collect::>>()?, + preloaded_kv: x.preloaded_kv.map(|v| convert_preloaded_kv_v6_to_v5(v)).transpose()?, + }) +} + +pub fn convert_stop_actor_reason_v6_to_v5(x: v6::StopActorReason) -> Result { + Ok(match x { + v6::StopActorReason::SleepIntent => v5::StopActorReason::SleepIntent, + v6::StopActorReason::StopIntent => v5::StopActorReason::StopIntent, + v6::StopActorReason::Destroy => v5::StopActorReason::Destroy, + v6::StopActorReason::GoingAway => v5::StopActorReason::GoingAway, + v6::StopActorReason::Lost => v5::StopActorReason::Lost, + }) +} + +pub fn convert_command_stop_actor_v6_to_v5(x: v6::CommandStopActor) -> Result { + Ok(v5::CommandStopActor { + reason: convert_stop_actor_reason_v6_to_v5(x.reason)?, + }) +} + +pub fn convert_command_v6_to_v5(x: v6::Command) -> Result { + Ok(match x { + v6::Command::CommandStartActor(v) => v5::Command::CommandStartActor(convert_command_start_actor_v6_to_v5(v)?), + v6::Command::CommandStopActor(v) => v5::Command::CommandStopActor(convert_command_stop_actor_v6_to_v5(v)?), + }) +} + +pub fn convert_command_wrapper_v6_to_v5(x: v6::CommandWrapper) -> Result { + Ok(v5::CommandWrapper { + checkpoint: convert_actor_checkpoint_v6_to_v5(x.checkpoint)?, + inner: convert_command_v6_to_v5(x.inner)?, + }) +} + +pub fn convert_actor_command_key_data_v6_to_v5(x: v6::ActorCommandKeyData) -> Result { + Ok(match x { + v6::ActorCommandKeyData::CommandStartActor(v) => v5::ActorCommandKeyData::CommandStartActor(convert_command_start_actor_v6_to_v5(v)?), + v6::ActorCommandKeyData::CommandStopActor(v) => v5::ActorCommandKeyData::CommandStopActor(convert_command_stop_actor_v6_to_v5(v)?), + }) +} + +pub fn convert_message_id_v6_to_v5(x: v6::MessageId) -> Result { + Ok(v5::MessageId { + gateway_id: x.gateway_id, + request_id: x.request_id, + message_index: x.message_index, + }) +} + +pub fn convert_to_envoy_request_start_v6_to_v5(x: v6::ToEnvoyRequestStart) -> Result { + Ok(v5::ToEnvoyRequestStart { + actor_id: x.actor_id, + method: x.method, + path: x.path, + headers: x.headers, + body: x.body, + stream: x.stream, + }) +} + +pub fn convert_to_envoy_request_chunk_v6_to_v5(x: v6::ToEnvoyRequestChunk) -> Result { + Ok(v5::ToEnvoyRequestChunk { + body: x.body, + finish: x.finish, + }) +} + +pub fn convert_to_rivet_response_start_v6_to_v5(x: v6::ToRivetResponseStart) -> Result { + Ok(v5::ToRivetResponseStart { + status: x.status, + headers: x.headers, + body: x.body, + stream: x.stream, + }) +} + +pub fn convert_to_rivet_response_chunk_v6_to_v5(x: v6::ToRivetResponseChunk) -> Result { + Ok(v5::ToRivetResponseChunk { + body: x.body, + finish: x.finish, + }) +} + +pub fn convert_to_envoy_web_socket_open_v6_to_v5(x: v6::ToEnvoyWebSocketOpen) -> Result { + Ok(v5::ToEnvoyWebSocketOpen { + actor_id: x.actor_id, + path: x.path, + headers: x.headers, + }) +} + +pub fn convert_to_envoy_web_socket_message_v6_to_v5(x: v6::ToEnvoyWebSocketMessage) -> Result { + Ok(v5::ToEnvoyWebSocketMessage { + data: x.data, + binary: x.binary, + }) +} + +pub fn convert_to_envoy_web_socket_close_v6_to_v5(x: v6::ToEnvoyWebSocketClose) -> Result { + Ok(v5::ToEnvoyWebSocketClose { + code: x.code, + reason: x.reason, + }) +} + +pub fn convert_to_rivet_web_socket_open_v6_to_v5(x: v6::ToRivetWebSocketOpen) -> Result { + Ok(v5::ToRivetWebSocketOpen { + can_hibernate: x.can_hibernate, + }) +} + +pub fn convert_to_rivet_web_socket_message_v6_to_v5(x: v6::ToRivetWebSocketMessage) -> Result { + Ok(v5::ToRivetWebSocketMessage { + data: x.data, + binary: x.binary, + }) +} + +pub fn convert_to_rivet_web_socket_message_ack_v6_to_v5(x: v6::ToRivetWebSocketMessageAck) -> Result { + Ok(v5::ToRivetWebSocketMessageAck { + index: x.index, + }) +} + +pub fn convert_to_rivet_web_socket_close_v6_to_v5(x: v6::ToRivetWebSocketClose) -> Result { + Ok(v5::ToRivetWebSocketClose { + code: x.code, + reason: x.reason, + hibernate: x.hibernate, + }) +} + +pub fn convert_to_rivet_tunnel_message_kind_v6_to_v5(x: v6::ToRivetTunnelMessageKind) -> Result { + Ok(match x { + v6::ToRivetTunnelMessageKind::ToRivetResponseStart(v) => v5::ToRivetTunnelMessageKind::ToRivetResponseStart(convert_to_rivet_response_start_v6_to_v5(v)?), + v6::ToRivetTunnelMessageKind::ToRivetResponseChunk(v) => v5::ToRivetTunnelMessageKind::ToRivetResponseChunk(convert_to_rivet_response_chunk_v6_to_v5(v)?), + v6::ToRivetTunnelMessageKind::ToRivetResponseAbort => v5::ToRivetTunnelMessageKind::ToRivetResponseAbort, + v6::ToRivetTunnelMessageKind::ToRivetWebSocketOpen(v) => v5::ToRivetTunnelMessageKind::ToRivetWebSocketOpen(convert_to_rivet_web_socket_open_v6_to_v5(v)?), + v6::ToRivetTunnelMessageKind::ToRivetWebSocketMessage(v) => v5::ToRivetTunnelMessageKind::ToRivetWebSocketMessage(convert_to_rivet_web_socket_message_v6_to_v5(v)?), + v6::ToRivetTunnelMessageKind::ToRivetWebSocketMessageAck(v) => v5::ToRivetTunnelMessageKind::ToRivetWebSocketMessageAck(convert_to_rivet_web_socket_message_ack_v6_to_v5(v)?), + v6::ToRivetTunnelMessageKind::ToRivetWebSocketClose(v) => v5::ToRivetTunnelMessageKind::ToRivetWebSocketClose(convert_to_rivet_web_socket_close_v6_to_v5(v)?), + }) +} + +pub fn convert_to_rivet_tunnel_message_v6_to_v5(x: v6::ToRivetTunnelMessage) -> Result { + Ok(v5::ToRivetTunnelMessage { + message_id: convert_message_id_v6_to_v5(x.message_id)?, + message_kind: convert_to_rivet_tunnel_message_kind_v6_to_v5(x.message_kind)?, + }) +} + +pub fn convert_to_envoy_tunnel_message_kind_v6_to_v5(x: v6::ToEnvoyTunnelMessageKind) -> Result { + Ok(match x { + v6::ToEnvoyTunnelMessageKind::ToEnvoyRequestStart(v) => v5::ToEnvoyTunnelMessageKind::ToEnvoyRequestStart(convert_to_envoy_request_start_v6_to_v5(v)?), + v6::ToEnvoyTunnelMessageKind::ToEnvoyRequestChunk(v) => v5::ToEnvoyTunnelMessageKind::ToEnvoyRequestChunk(convert_to_envoy_request_chunk_v6_to_v5(v)?), + v6::ToEnvoyTunnelMessageKind::ToEnvoyRequestAbort => v5::ToEnvoyTunnelMessageKind::ToEnvoyRequestAbort, + v6::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketOpen(v) => v5::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketOpen(convert_to_envoy_web_socket_open_v6_to_v5(v)?), + v6::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketMessage(v) => v5::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketMessage(convert_to_envoy_web_socket_message_v6_to_v5(v)?), + v6::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketClose(v) => v5::ToEnvoyTunnelMessageKind::ToEnvoyWebSocketClose(convert_to_envoy_web_socket_close_v6_to_v5(v)?), + }) +} + +pub fn convert_to_envoy_tunnel_message_v6_to_v5(x: v6::ToEnvoyTunnelMessage) -> Result { + Ok(v5::ToEnvoyTunnelMessage { + message_id: convert_message_id_v6_to_v5(x.message_id)?, + message_kind: convert_to_envoy_tunnel_message_kind_v6_to_v5(x.message_kind)?, + }) +} + +pub fn convert_to_envoy_ping_v6_to_v5(x: v6::ToEnvoyPing) -> Result { + Ok(v5::ToEnvoyPing { + ts: x.ts, + }) +} + +pub fn convert_to_rivet_metadata_v6_to_v5(x: v6::ToRivetMetadata) -> Result { + Ok(v5::ToRivetMetadata { + prepopulate_actor_names: x.prepopulate_actor_names.map(|v| v.into_iter().map(|(k, v)| -> Result<_> { Ok((k, convert_actor_name_v6_to_v5(v)?)) }).collect::>()).transpose()?, + metadata: x.metadata, + }) +} + +pub fn convert_to_rivet_events_v6_to_v5(x: v6::ToRivetEvents) -> Result { + Ok(x.into_iter().map(|v| convert_event_wrapper_v6_to_v5(v)).collect::>>()?) +} + +pub fn convert_to_rivet_ack_commands_v6_to_v5(x: v6::ToRivetAckCommands) -> Result { + Ok(v5::ToRivetAckCommands { + last_command_checkpoints: x.last_command_checkpoints.into_iter().map(|v| convert_actor_checkpoint_v6_to_v5(v)).collect::>>()?, + }) +} + +pub fn convert_to_rivet_pong_v6_to_v5(x: v6::ToRivetPong) -> Result { + Ok(v5::ToRivetPong { + ts: x.ts, + }) +} + +pub fn convert_to_rivet_kv_request_v6_to_v5(x: v6::ToRivetKvRequest) -> Result { + Ok(v5::ToRivetKvRequest { + actor_id: x.actor_id, + request_id: x.request_id, + data: convert_kv_request_data_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_rivet_sqlite_get_pages_request_v6_to_v5(x: v6::ToRivetSqliteGetPagesRequest) -> Result { + Ok(v5::ToRivetSqliteGetPagesRequest { + request_id: x.request_id, + data: convert_sqlite_get_pages_request_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_rivet_sqlite_commit_request_v6_to_v5(x: v6::ToRivetSqliteCommitRequest) -> Result { + Ok(v5::ToRivetSqliteCommitRequest { + request_id: x.request_id, + data: convert_sqlite_commit_request_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_rivet_sqlite_exec_request_v6_to_v5(x: v6::ToRivetSqliteExecRequest) -> Result { + Ok(v5::ToRivetSqliteExecRequest { + request_id: x.request_id, + data: convert_sqlite_exec_request_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_rivet_sqlite_execute_request_v6_to_v5(x: v6::ToRivetSqliteExecuteRequest) -> Result { + Ok(v5::ToRivetSqliteExecuteRequest { + request_id: x.request_id, + data: convert_sqlite_execute_request_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_rivet_v6_to_v5(x: v6::ToRivet) -> Result { + Ok(match x { + v6::ToRivet::ToRivetMetadata(v) => v5::ToRivet::ToRivetMetadata(convert_to_rivet_metadata_v6_to_v5(v)?), + v6::ToRivet::ToRivetEvents(v) => v5::ToRivet::ToRivetEvents(convert_to_rivet_events_v6_to_v5(v)?), + v6::ToRivet::ToRivetAckCommands(v) => v5::ToRivet::ToRivetAckCommands(convert_to_rivet_ack_commands_v6_to_v5(v)?), + v6::ToRivet::ToRivetStopping => v5::ToRivet::ToRivetStopping, + v6::ToRivet::ToRivetPong(v) => v5::ToRivet::ToRivetPong(convert_to_rivet_pong_v6_to_v5(v)?), + v6::ToRivet::ToRivetKvRequest(v) => v5::ToRivet::ToRivetKvRequest(convert_to_rivet_kv_request_v6_to_v5(v)?), + v6::ToRivet::ToRivetTunnelMessage(v) => v5::ToRivet::ToRivetTunnelMessage(convert_to_rivet_tunnel_message_v6_to_v5(v)?), + v6::ToRivet::ToRivetSqliteGetPagesRequest(v) => v5::ToRivet::ToRivetSqliteGetPagesRequest(convert_to_rivet_sqlite_get_pages_request_v6_to_v5(v)?), + v6::ToRivet::ToRivetSqliteCommitRequest(v) => v5::ToRivet::ToRivetSqliteCommitRequest(convert_to_rivet_sqlite_commit_request_v6_to_v5(v)?), + v6::ToRivet::ToRivetSqliteExecRequest(v) => v5::ToRivet::ToRivetSqliteExecRequest(convert_to_rivet_sqlite_exec_request_v6_to_v5(v)?), + v6::ToRivet::ToRivetSqliteExecuteRequest(v) => v5::ToRivet::ToRivetSqliteExecuteRequest(convert_to_rivet_sqlite_execute_request_v6_to_v5(v)?), + v6::ToRivet::ToRivetSqliteExecuteBatchRequest(_) => { + return Err(incompatible( + ProtocolCompatibilityFeature::RemoteSqliteBatchExecution, + ProtocolCompatibilityDirection::ToRivet, + 6, + 5, + )); + } + }) +} + +pub fn convert_protocol_metadata_v6_to_v5(x: v6::ProtocolMetadata) -> Result { + Ok(v5::ProtocolMetadata { + envoy_lost_threshold: x.envoy_lost_threshold, + actor_stop_threshold: x.actor_stop_threshold, + max_response_payload_size: x.max_response_payload_size, + }) +} + +pub fn convert_to_envoy_init_v6_to_v5(x: v6::ToEnvoyInit) -> Result { + Ok(v5::ToEnvoyInit { + metadata: convert_protocol_metadata_v6_to_v5(x.metadata)?, + }) +} + +pub fn convert_to_envoy_commands_v6_to_v5(x: v6::ToEnvoyCommands) -> Result { + Ok(x.into_iter().map(|v| convert_command_wrapper_v6_to_v5(v)).collect::>>()?) +} + +pub fn convert_to_envoy_ack_events_v6_to_v5(x: v6::ToEnvoyAckEvents) -> Result { + Ok(v5::ToEnvoyAckEvents { + last_event_checkpoints: x.last_event_checkpoints.into_iter().map(|v| convert_actor_checkpoint_v6_to_v5(v)).collect::>>()?, + }) +} + +pub fn convert_to_envoy_kv_response_v6_to_v5(x: v6::ToEnvoyKvResponse) -> Result { + Ok(v5::ToEnvoyKvResponse { + request_id: x.request_id, + data: convert_kv_response_data_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_envoy_sqlite_get_pages_response_v6_to_v5(x: v6::ToEnvoySqliteGetPagesResponse) -> Result { + Ok(v5::ToEnvoySqliteGetPagesResponse { + request_id: x.request_id, + data: convert_sqlite_get_pages_response_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_envoy_sqlite_commit_response_v6_to_v5(x: v6::ToEnvoySqliteCommitResponse) -> Result { + Ok(v5::ToEnvoySqliteCommitResponse { + request_id: x.request_id, + data: convert_sqlite_commit_response_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_envoy_sqlite_exec_response_v6_to_v5(x: v6::ToEnvoySqliteExecResponse) -> Result { + Ok(v5::ToEnvoySqliteExecResponse { + request_id: x.request_id, + data: convert_sqlite_exec_response_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_envoy_sqlite_execute_response_v6_to_v5(x: v6::ToEnvoySqliteExecuteResponse) -> Result { + Ok(v5::ToEnvoySqliteExecuteResponse { + request_id: x.request_id, + data: convert_sqlite_execute_response_v6_to_v5(x.data)?, + }) +} + +pub fn convert_to_envoy_v6_to_v5(x: v6::ToEnvoy) -> Result { + Ok(match x { + v6::ToEnvoy::ToEnvoyInit(v) => v5::ToEnvoy::ToEnvoyInit(convert_to_envoy_init_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoyCommands(v) => v5::ToEnvoy::ToEnvoyCommands(convert_to_envoy_commands_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoyAckEvents(v) => v5::ToEnvoy::ToEnvoyAckEvents(convert_to_envoy_ack_events_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoyKvResponse(v) => v5::ToEnvoy::ToEnvoyKvResponse(convert_to_envoy_kv_response_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoyTunnelMessage(v) => v5::ToEnvoy::ToEnvoyTunnelMessage(convert_to_envoy_tunnel_message_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoyPing(v) => v5::ToEnvoy::ToEnvoyPing(convert_to_envoy_ping_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoySqliteGetPagesResponse(v) => v5::ToEnvoy::ToEnvoySqliteGetPagesResponse(convert_to_envoy_sqlite_get_pages_response_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoySqliteCommitResponse(v) => v5::ToEnvoy::ToEnvoySqliteCommitResponse(convert_to_envoy_sqlite_commit_response_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoySqliteExecResponse(v) => v5::ToEnvoy::ToEnvoySqliteExecResponse(convert_to_envoy_sqlite_exec_response_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoySqliteExecuteResponse(v) => v5::ToEnvoy::ToEnvoySqliteExecuteResponse(convert_to_envoy_sqlite_execute_response_v6_to_v5(v)?), + v6::ToEnvoy::ToEnvoySqliteExecuteBatchResponse(_) => { + return Err(incompatible( + ProtocolCompatibilityFeature::RemoteSqliteBatchExecution, + ProtocolCompatibilityDirection::ToEnvoy, + 6, + 5, + )); + } + }) +} + +pub fn convert_to_envoy_conn_ping_v6_to_v5(x: v6::ToEnvoyConnPing) -> Result { + Ok(v5::ToEnvoyConnPing { + gateway_id: x.gateway_id, + request_id: x.request_id, + ts: x.ts, + }) +} + +pub fn convert_to_envoy_conn_v6_to_v5(x: v6::ToEnvoyConn) -> Result { + Ok(match x { + v6::ToEnvoyConn::ToEnvoyConnPing(v) => v5::ToEnvoyConn::ToEnvoyConnPing(convert_to_envoy_conn_ping_v6_to_v5(v)?), + v6::ToEnvoyConn::ToEnvoyConnClose => v5::ToEnvoyConn::ToEnvoyConnClose, + v6::ToEnvoyConn::ToEnvoyCommands(v) => v5::ToEnvoyConn::ToEnvoyCommands(convert_to_envoy_commands_v6_to_v5(v)?), + v6::ToEnvoyConn::ToEnvoyAckEvents(v) => v5::ToEnvoyConn::ToEnvoyAckEvents(convert_to_envoy_ack_events_v6_to_v5(v)?), + v6::ToEnvoyConn::ToEnvoyTunnelMessage(v) => v5::ToEnvoyConn::ToEnvoyTunnelMessage(convert_to_envoy_tunnel_message_v6_to_v5(v)?), + }) +} + +pub fn convert_to_gateway_pong_v6_to_v5(x: v6::ToGatewayPong) -> Result { + Ok(v5::ToGatewayPong { + request_id: x.request_id, + ts: x.ts, + }) +} + +pub fn convert_to_gateway_v6_to_v5(x: v6::ToGateway) -> Result { + Ok(match x { + v6::ToGateway::ToGatewayPong(v) => v5::ToGateway::ToGatewayPong(convert_to_gateway_pong_v6_to_v5(v)?), + v6::ToGateway::ToRivetTunnelMessage(v) => v5::ToGateway::ToRivetTunnelMessage(convert_to_rivet_tunnel_message_v6_to_v5(v)?), + }) +} + +pub fn convert_to_outbound_actor_start_v6_to_v5(x: v6::ToOutboundActorStart) -> Result { + Ok(v5::ToOutboundActorStart { + namespace_id: x.namespace_id, + pool_name: x.pool_name, + checkpoint: convert_actor_checkpoint_v6_to_v5(x.checkpoint)?, + actor_config: convert_actor_config_v6_to_v5(x.actor_config)?, + }) +} + +pub fn convert_to_outbound_v6_to_v5(x: v6::ToOutbound) -> Result { + Ok(match x { + v6::ToOutbound::ToOutboundActorStart(v) => v5::ToOutbound::ToOutboundActorStart(convert_to_outbound_actor_start_v6_to_v5(v)?), + }) +} diff --git a/engine/sdks/rust/envoy-protocol/tests/remote_sql_compat.rs b/engine/sdks/rust/envoy-protocol/tests/remote_sql_compat.rs index 2fca661df7..c4d655dfce 100644 --- a/engine/sdks/rust/envoy-protocol/tests/remote_sql_compat.rs +++ b/engine/sdks/rust/envoy-protocol/tests/remote_sql_compat.rs @@ -1,6 +1,6 @@ use anyhow::Result; use rivet_envoy_protocol::{ - generated::{v4, v5}, + generated::{v4, v6}, versioned::{ ProtocolCompatibilityDirection, ProtocolCompatibilityError, ProtocolCompatibilityFeature, ToEnvoy, ToRivet, @@ -8,10 +8,10 @@ use rivet_envoy_protocol::{ }; use vbare::OwnedVersionedData; -fn remote_sql_request_exec() -> v5::ToRivet { - v5::ToRivet::ToRivetSqliteExecRequest(v5::ToRivetSqliteExecRequest { +fn remote_sql_request_exec() -> v6::ToRivet { + v6::ToRivet::ToRivetSqliteExecRequest(v6::ToRivetSqliteExecRequest { request_id: 1, - data: v5::SqliteExecRequest { + data: v6::SqliteExecRequest { namespace_id: "namespace".into(), actor_id: "actor".into(), generation: 7, @@ -20,25 +20,25 @@ fn remote_sql_request_exec() -> v5::ToRivet { }) } -fn remote_sql_request_execute() -> v5::ToRivet { - v5::ToRivet::ToRivetSqliteExecuteRequest(v5::ToRivetSqliteExecuteRequest { +fn remote_sql_request_execute() -> v6::ToRivet { + v6::ToRivet::ToRivetSqliteExecuteRequest(v6::ToRivetSqliteExecuteRequest { request_id: 2, - data: v5::SqliteExecuteRequest { + data: v6::SqliteExecuteRequest { namespace_id: "namespace".into(), actor_id: "actor".into(), generation: 7, sql: "select ?".into(), - params: Some(vec![v5::SqliteBindParam::SqliteValueInteger( - v5::SqliteValueInteger { value: 1 }, + params: Some(vec![v6::SqliteBindParam::SqliteValueInteger( + v6::SqliteValueInteger { value: 1 }, )]), }, }) } -fn remote_sql_response_exec() -> v5::ToEnvoy { - v5::ToEnvoy::ToEnvoySqliteExecResponse(v5::ToEnvoySqliteExecResponse { +fn remote_sql_response_exec() -> v6::ToEnvoy { + v6::ToEnvoy::ToEnvoySqliteExecResponse(v6::ToEnvoySqliteExecResponse { request_id: 1, - data: v5::SqliteExecResponse::SqliteErrorResponse(v5::SqliteErrorResponse { + data: v6::SqliteExecResponse::SqliteErrorResponse(v6::SqliteErrorResponse { group: "sqlite".into(), code: "remote_unavailable".into(), message: "remote sql execution is unavailable".into(), @@ -46,10 +46,10 @@ fn remote_sql_response_exec() -> v5::ToEnvoy { }) } -fn remote_sql_response_execute() -> v5::ToEnvoy { - v5::ToEnvoy::ToEnvoySqliteExecuteResponse(v5::ToEnvoySqliteExecuteResponse { +fn remote_sql_response_execute() -> v6::ToEnvoy { + v6::ToEnvoy::ToEnvoySqliteExecuteResponse(v6::ToEnvoySqliteExecuteResponse { request_id: 2, - data: v5::SqliteExecuteResponse::SqliteErrorResponse(v5::SqliteErrorResponse { + data: v6::SqliteExecuteResponse::SqliteErrorResponse(v6::SqliteErrorResponse { group: "sqlite".into(), code: "remote_unavailable".into(), message: "remote sql execution is unavailable".into(), @@ -57,6 +57,32 @@ fn remote_sql_response_execute() -> v5::ToEnvoy { }) } +fn remote_sql_request_execute_batch() -> v6::ToRivet { + v6::ToRivet::ToRivetSqliteExecuteBatchRequest(v6::ToRivetSqliteExecuteBatchRequest { + request_id: 3, + data: v6::SqliteExecuteBatchRequest { + namespace_id: "namespace".into(), + actor_id: "actor".into(), + generation: 7, + statements: vec![v6::SqliteBatchStatement { + sql: "insert into t values (?)".into(), + params: Some(vec![v6::SqliteBindParam::SqliteValueInteger( + v6::SqliteValueInteger { value: 1 }, + )]), + }], + }, + }) +} + +fn remote_sql_response_execute_batch() -> v6::ToEnvoy { + v6::ToEnvoy::ToEnvoySqliteExecuteBatchResponse(v6::ToEnvoySqliteExecuteBatchResponse { + request_id: 3, + data: v6::SqliteExecuteBatchResponse::SqliteExecuteBatchOk(v6::SqliteExecuteBatchOk { + results: Vec::new(), + }), + }) +} + fn assert_compatibility_error( err: anyhow::Error, direction: ProtocolCompatibilityDirection, @@ -113,11 +139,11 @@ fn new_core_new_pegboard_envoy_allows_remote_sql_both_directions() -> Result<()> assert!(matches!( ToRivet::deserialize(&request, 4)?, - v5::ToRivet::ToRivetSqliteExecRequest(_) + v6::ToRivet::ToRivetSqliteExecRequest(_) )); assert!(matches!( ToEnvoy::deserialize(&response, 4)?, - v5::ToEnvoy::ToEnvoySqliteExecResponse(_) + v6::ToEnvoy::ToEnvoySqliteExecResponse(_) )); Ok(()) @@ -179,3 +205,41 @@ fn all_remote_sql_response_variants_require_v4() { } } } + +#[test] +fn remote_sql_batch_requires_v6() -> Result<()> { + let request_error = ToRivet::wrap_latest(remote_sql_request_execute_batch()) + .serialize(5) + .expect_err("remote SQL batch requests must not serialize below v6"); + let response_error = ToEnvoy::wrap_latest(remote_sql_response_execute_batch()) + .serialize(5) + .expect_err("remote SQL batch responses must not serialize below v6"); + + for (error, direction) in [ + (request_error, ProtocolCompatibilityDirection::ToRivet), + (response_error, ProtocolCompatibilityDirection::ToEnvoy), + ] { + let error = error + .downcast_ref::() + .expect("expected structured protocol compatibility error"); + assert_eq!( + error.feature, + ProtocolCompatibilityFeature::RemoteSqliteBatchExecution + ); + assert_eq!(error.direction, direction); + assert_eq!(error.required_version, 6); + assert_eq!(error.target_version, 5); + } + + let request = ToRivet::wrap_latest(remote_sql_request_execute_batch()).serialize(6)?; + let response = ToEnvoy::wrap_latest(remote_sql_response_execute_batch()).serialize(6)?; + assert!(matches!( + ToRivet::deserialize(&request, 6)?, + v6::ToRivet::ToRivetSqliteExecuteBatchRequest(_) + )); + assert!(matches!( + ToEnvoy::deserialize(&response, 6)?, + v6::ToEnvoy::ToEnvoySqliteExecuteBatchResponse(_) + )); + Ok(()) +} diff --git a/engine/sdks/rust/envoy-protocol/tests/stateless_sqlite_v3.rs b/engine/sdks/rust/envoy-protocol/tests/stateless_sqlite_v3.rs index 13dfdd105e..8f79d0e634 100644 --- a/engine/sdks/rust/envoy-protocol/tests/stateless_sqlite_v3.rs +++ b/engine/sdks/rust/envoy-protocol/tests/stateless_sqlite_v3.rs @@ -168,7 +168,7 @@ fn expected_generation_optional_present_and_absent() -> anyhow::Result<()> { #[test] fn protocol_version_constant_matches_schema_version() { - assert_eq!(PROTOCOL_VERSION, 5); + assert_eq!(PROTOCOL_VERSION, 6); } #[test] diff --git a/engine/sdks/schemas/envoy-protocol/v6.bare b/engine/sdks/schemas/envoy-protocol/v6.bare new file mode 100644 index 0000000000..0e41e261b1 --- /dev/null +++ b/engine/sdks/schemas/envoy-protocol/v6.bare @@ -0,0 +1,675 @@ +# MARK: Core Primitives + +type Id str +type Json str + +type GatewayId data[4] +type RequestId data[4] +type MessageIndex u16 + +# MARK: KV + +# Basic types +type KvKey data +type KvValue data +type KvMetadata struct { + version: data + updateTs: i64 +} + +# Query types +type KvListAllQuery void +type KvListRangeQuery struct { + start: KvKey + end: KvKey + exclusive: bool +} + +type KvListPrefixQuery struct { + key: KvKey +} + +type KvListQuery union { + KvListAllQuery | + KvListRangeQuery | + KvListPrefixQuery +} + +# Request types +type KvGetRequest struct { + keys: list +} + +type KvListRequest struct { + query: KvListQuery + reverse: optional + limit: optional +} + +type KvPutRequest struct { + keys: list + values: list +} + +type KvDeleteRequest struct { + keys: list +} + +type KvDeleteRangeRequest struct { + start: KvKey + end: KvKey +} + +type KvDropRequest void + +# Response types +type KvErrorResponse struct { + message: str +} + +type KvGetResponse struct { + keys: list + values: list + metadata: list +} + +type KvListResponse struct { + keys: list + values: list + metadata: list +} + +type KvPutResponse void +type KvDeleteResponse void +type KvDropResponse void + +# Request/Response unions +type KvRequestData union { + KvGetRequest | + KvListRequest | + KvPutRequest | + KvDeleteRequest | + KvDeleteRangeRequest | + KvDropRequest +} + +type KvResponseData union { + KvErrorResponse | + KvGetResponse | + KvListResponse | + KvPutResponse | + KvDeleteResponse | + KvDropResponse +} + +# MARK: SQLite + +type SqlitePgno u32 +type SqliteGeneration u64 +type SqlitePageBytes data + +type SqliteDirtyPage struct { + pgno: SqlitePgno + bytes: SqlitePageBytes +} + +type SqliteFetchedPage struct { + pgno: SqlitePgno + bytes: optional +} + +type SqliteGetPagesRequest struct { + actorId: Id + pgnos: list + expectedGeneration: optional + expectedHeadTxid: optional +} + +type SqliteGetPagesOk struct { + pages: list + headTxid: optional +} + +type SqliteErrorResponse struct { + group: str + code: str + message: str +} + +type SqliteGetPagesResponse union { + SqliteGetPagesOk | + SqliteErrorResponse +} + +type SqliteCommitRequest struct { + actorId: Id + dirtyPages: list + dbSizePages: u32 + nowMs: i64 + expectedGeneration: optional + expectedHeadTxid: optional +} + +type SqliteCommitOk struct { + headTxid: optional +} + +type SqliteCommitResponse union { + SqliteCommitOk | + SqliteErrorResponse +} + +# MARK: SQLite Remote Execution + +type SqliteValueNull void + +type SqliteValueInteger struct { + value: i64 +} + +type SqliteValueFloat struct { + value: data[8] +} + +type SqliteValueText struct { + value: str +} + +type SqliteValueBlob struct { + value: data +} + +type SqliteBindParam union { + SqliteValueNull | + SqliteValueInteger | + SqliteValueFloat | + SqliteValueText | + SqliteValueBlob +} + +type SqliteColumnValue union { + SqliteValueNull | + SqliteValueInteger | + SqliteValueFloat | + SqliteValueText | + SqliteValueBlob +} + +type SqliteQueryResult struct { + columns: list + rows: list> +} + +type SqliteExecuteResult struct { + columns: list + rows: list> + changes: i64 + lastInsertRowId: optional +} + +type SqliteExecRequest struct { + namespaceId: Id + actorId: Id + generation: SqliteGeneration + sql: str +} + +type SqliteExecuteRequest struct { + namespaceId: Id + actorId: Id + generation: SqliteGeneration + sql: str + params: optional> +} + +type SqliteBatchStatement struct { + sql: str + params: optional> +} + +type SqliteExecuteBatchRequest struct { + namespaceId: Id + actorId: Id + generation: SqliteGeneration + statements: list +} + +type SqliteExecOk struct { + result: SqliteQueryResult +} + +type SqliteExecuteOk struct { + result: SqliteExecuteResult +} + +type SqliteExecuteBatchOk struct { + results: list +} + +type SqliteExecResponse union { + SqliteExecOk | + SqliteErrorResponse +} + +type SqliteExecuteResponse union { + SqliteExecuteOk | + SqliteErrorResponse +} + +type SqliteExecuteBatchResponse union { + SqliteExecuteBatchOk | + SqliteErrorResponse +} + +# MARK: Actor + +# Core +type StopCode enum { + OK + ERROR +} + +type ActorName struct { + metadata: Json +} + +type ActorConfig struct { + name: str + key: optional + createTs: i64 + input: optional +} + +type ActorCheckpoint struct { + actorId: Id + generation: u32 + index: i64 +} + +# Intent +type ActorIntentSleep void + +type ActorIntentStop void + +type ActorIntent union { + ActorIntentSleep | + ActorIntentStop +} + +# State +type ActorStateRunning void + +type ActorStateStopped struct { + code: StopCode + message: optional +} + +type ActorState union { + ActorStateRunning | + ActorStateStopped +} + +# MARK: Events +type EventActorIntent struct { + intent: ActorIntent +} + +type EventActorStateUpdate struct { + state: ActorState +} + +type EventActorSetAlarm struct { + alarmTs: optional +} + +type Event union { + EventActorIntent | + EventActorStateUpdate | + EventActorSetAlarm +} + +type EventWrapper struct { + checkpoint: ActorCheckpoint + inner: Event +} + +# MARK: Preloaded KV + +type PreloadedKvEntry struct { + key: KvKey + value: KvValue + metadata: KvMetadata +} + +type PreloadedKv struct { + entries: list + requestedGetKeys: list + requestedPrefixes: list +} + +# MARK: Commands + +type HibernatingRequest struct { + gatewayId: GatewayId + requestId: RequestId +} + +type CommandStartActor struct { + config: ActorConfig + hibernatingRequests: list + preloadedKv: optional +} + +type StopActorReason enum { + SLEEP_INTENT + STOP_INTENT + DESTROY + GOING_AWAY + LOST +} + +type CommandStopActor struct { + reason: StopActorReason +} + +type Command union { + CommandStartActor | + CommandStopActor +} + +type CommandWrapper struct { + checkpoint: ActorCheckpoint + inner: Command +} + +# We redeclare this so its top level +type ActorCommandKeyData union { + CommandStartActor | + CommandStopActor +} + +# MARK: Tunnel + +# Message ID + +type MessageId struct { + # Globally unique ID + gatewayId: GatewayId + # Unique ID to the gateway + requestId: RequestId + # Unique ID to the request + messageIndex: MessageIndex +} + +# HTTP +type ToEnvoyRequestStart struct { + actorId: Id + method: str + path: str + headers: map + body: optional + stream: bool +} + +type ToEnvoyRequestChunk struct { + body: data + finish: bool +} + +type ToEnvoyRequestAbort void + +type ToRivetResponseStart struct { + status: u16 + headers: map + body: optional + stream: bool +} + +type ToRivetResponseChunk struct { + body: data + finish: bool +} + +type ToRivetResponseAbort void + +# WebSocket +type ToEnvoyWebSocketOpen struct { + actorId: Id + path: str + headers: map +} + +type ToEnvoyWebSocketMessage struct { + data: data + binary: bool +} + +type ToEnvoyWebSocketClose struct { + code: optional + reason: optional +} + +type ToRivetWebSocketOpen struct { + canHibernate: bool +} + +type ToRivetWebSocketMessage struct { + data: data + binary: bool +} + +type ToRivetWebSocketMessageAck struct { + index: MessageIndex +} + +type ToRivetWebSocketClose struct { + code: optional + reason: optional + hibernate: bool +} + +# To Rivet +type ToRivetTunnelMessageKind union { + # HTTP + ToRivetResponseStart | + ToRivetResponseChunk | + ToRivetResponseAbort | + + # WebSocket + ToRivetWebSocketOpen | + ToRivetWebSocketMessage | + ToRivetWebSocketMessageAck | + ToRivetWebSocketClose +} + +type ToRivetTunnelMessage struct { + messageId: MessageId + messageKind: ToRivetTunnelMessageKind +} + +# To Envoy +type ToEnvoyTunnelMessageKind union { + # HTTP + ToEnvoyRequestStart | + ToEnvoyRequestChunk | + ToEnvoyRequestAbort | + + # WebSocket + ToEnvoyWebSocketOpen | + ToEnvoyWebSocketMessage | + ToEnvoyWebSocketClose +} + +type ToEnvoyTunnelMessage struct { + messageId: MessageId + messageKind: ToEnvoyTunnelMessageKind +} + +type ToEnvoyPing struct { + ts: i64 +} + +# MARK: To Rivet +type ToRivetMetadata struct { + prepopulateActorNames: optional> + metadata: optional +} + +type ToRivetEvents list + +type ToRivetAckCommands struct { + lastCommandCheckpoints: list +} + +type ToRivetStopping void + +type ToRivetPong struct { + ts: i64 +} + +type ToRivetKvRequest struct { + actorId: Id + requestId: u32 + data: KvRequestData +} + +type ToRivetSqliteGetPagesRequest struct { + requestId: u32 + data: SqliteGetPagesRequest +} + +type ToRivetSqliteCommitRequest struct { + requestId: u32 + data: SqliteCommitRequest +} + +type ToRivetSqliteExecRequest struct { + requestId: u32 + data: SqliteExecRequest +} + +type ToRivetSqliteExecuteRequest struct { + requestId: u32 + data: SqliteExecuteRequest +} + +type ToRivetSqliteExecuteBatchRequest struct { + requestId: u32 + data: SqliteExecuteBatchRequest +} + +type ToRivet union { + ToRivetMetadata | + ToRivetEvents | + ToRivetAckCommands | + ToRivetStopping | + ToRivetPong | + ToRivetKvRequest | + ToRivetTunnelMessage | + ToRivetSqliteGetPagesRequest | + ToRivetSqliteCommitRequest | + ToRivetSqliteExecRequest | + ToRivetSqliteExecuteRequest | + ToRivetSqliteExecuteBatchRequest +} + +# MARK: To Envoy +type ProtocolMetadata struct { + envoyLostThreshold: i64 + actorStopThreshold: i64 + maxResponsePayloadSize: u64 +} + +type ToEnvoyInit struct { + metadata: ProtocolMetadata +} + +type ToEnvoyCommands list + +type ToEnvoyAckEvents struct { + lastEventCheckpoints: list +} + +type ToEnvoyKvResponse struct { + requestId: u32 + data: KvResponseData +} + +type ToEnvoySqliteGetPagesResponse struct { + requestId: u32 + data: SqliteGetPagesResponse +} + +type ToEnvoySqliteCommitResponse struct { + requestId: u32 + data: SqliteCommitResponse +} + +type ToEnvoySqliteExecResponse struct { + requestId: u32 + data: SqliteExecResponse +} + +type ToEnvoySqliteExecuteResponse struct { + requestId: u32 + data: SqliteExecuteResponse +} + +type ToEnvoySqliteExecuteBatchResponse struct { + requestId: u32 + data: SqliteExecuteBatchResponse +} + +type ToEnvoy union { + ToEnvoyInit | + ToEnvoyCommands | + ToEnvoyAckEvents | + ToEnvoyKvResponse | + ToEnvoyTunnelMessage | + ToEnvoyPing | + ToEnvoySqliteGetPagesResponse | + ToEnvoySqliteCommitResponse | + ToEnvoySqliteExecResponse | + ToEnvoySqliteExecuteResponse | + ToEnvoySqliteExecuteBatchResponse +} + +# MARK: To Envoy Conn +type ToEnvoyConnPing struct { + gatewayId: GatewayId + requestId: RequestId + ts: i64 +} + +type ToEnvoyConnClose void + +type ToEnvoyConn union { + ToEnvoyConnPing | + ToEnvoyConnClose | + ToEnvoyCommands | + ToEnvoyAckEvents | + ToEnvoyTunnelMessage +} + +# MARK: To Gateway +type ToGatewayPong struct { + requestId: RequestId + ts: i64 +} + +type ToGateway union { + ToGatewayPong | + ToRivetTunnelMessage +} + +# MARK: To Outbound +type ToOutboundActorStart struct { + namespaceId: Id + poolName: str + checkpoint: ActorCheckpoint + actorConfig: ActorConfig +} + +type ToOutbound union { + ToOutboundActorStart +} diff --git a/engine/sdks/typescript/envoy-protocol/src/index.ts b/engine/sdks/typescript/envoy-protocol/src/index.ts index 5f186af919..a98847f1e5 100644 --- a/engine/sdks/typescript/envoy-protocol/src/index.ts +++ b/engine/sdks/typescript/envoy-protocol/src/index.ts @@ -1206,6 +1206,65 @@ export function writeSqliteExecuteRequest(bc: bare.ByteCursor, x: SqliteExecuteR write14(bc, x.params) } +export type SqliteBatchStatement = { + readonly sql: string + readonly params: readonly SqliteBindParam[] | null +} + +export function readSqliteBatchStatement(bc: bare.ByteCursor): SqliteBatchStatement { + return { + sql: bare.readString(bc), + params: read14(bc), + } +} + +export function writeSqliteBatchStatement(bc: bare.ByteCursor, x: SqliteBatchStatement): void { + bare.writeString(bc, x.sql) + write14(bc, x.params) +} + +function read15(bc: bare.ByteCursor): readonly SqliteBatchStatement[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readSqliteBatchStatement(bc)] + for (let i = 1; i < len; i++) { + result[i] = readSqliteBatchStatement(bc) + } + return result +} + +function write15(bc: bare.ByteCursor, x: readonly SqliteBatchStatement[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeSqliteBatchStatement(bc, x[i]) + } +} + +export type SqliteExecuteBatchRequest = { + readonly namespaceId: Id + readonly actorId: Id + readonly generation: SqliteGeneration + readonly statements: readonly SqliteBatchStatement[] +} + +export function readSqliteExecuteBatchRequest(bc: bare.ByteCursor): SqliteExecuteBatchRequest { + return { + namespaceId: readId(bc), + actorId: readId(bc), + generation: readSqliteGeneration(bc), + statements: read15(bc), + } +} + +export function writeSqliteExecuteBatchRequest(bc: bare.ByteCursor, x: SqliteExecuteBatchRequest): void { + writeId(bc, x.namespaceId) + writeId(bc, x.actorId) + writeSqliteGeneration(bc, x.generation) + write15(bc, x.statements) +} + export type SqliteExecOk = { readonly result: SqliteQueryResult } @@ -1234,6 +1293,39 @@ export function writeSqliteExecuteOk(bc: bare.ByteCursor, x: SqliteExecuteOk): v writeSqliteExecuteResult(bc, x.result) } +function read16(bc: bare.ByteCursor): readonly SqliteExecuteResult[] { + const len = bare.readUintSafe(bc) + if (len === 0) { + return [] + } + const result = [readSqliteExecuteResult(bc)] + for (let i = 1; i < len; i++) { + result[i] = readSqliteExecuteResult(bc) + } + return result +} + +function write16(bc: bare.ByteCursor, x: readonly SqliteExecuteResult[]): void { + bare.writeUintSafe(bc, x.length) + for (let i = 0; i < x.length; i++) { + writeSqliteExecuteResult(bc, x[i]) + } +} + +export type SqliteExecuteBatchOk = { + readonly results: readonly SqliteExecuteResult[] +} + +export function readSqliteExecuteBatchOk(bc: bare.ByteCursor): SqliteExecuteBatchOk { + return { + results: read16(bc), + } +} + +export function writeSqliteExecuteBatchOk(bc: bare.ByteCursor, x: SqliteExecuteBatchOk): void { + write16(bc, x.results) +} + export type SqliteExecResponse = | { readonly tag: "SqliteExecOk"; readonly val: SqliteExecOk } | { readonly tag: "SqliteErrorResponse"; readonly val: SqliteErrorResponse } @@ -1302,6 +1394,40 @@ export function writeSqliteExecuteResponse(bc: bare.ByteCursor, x: SqliteExecute } } +export type SqliteExecuteBatchResponse = + | { readonly tag: "SqliteExecuteBatchOk"; readonly val: SqliteExecuteBatchOk } + | { readonly tag: "SqliteErrorResponse"; readonly val: SqliteErrorResponse } + +export function readSqliteExecuteBatchResponse(bc: bare.ByteCursor): SqliteExecuteBatchResponse { + const offset = bc.offset + const tag = bare.readU8(bc) + switch (tag) { + case 0: + return { tag: "SqliteExecuteBatchOk", val: readSqliteExecuteBatchOk(bc) } + case 1: + return { tag: "SqliteErrorResponse", val: readSqliteErrorResponse(bc) } + default: { + bc.offset = offset + throw new bare.BareError(offset, "invalid tag") + } + } +} + +export function writeSqliteExecuteBatchResponse(bc: bare.ByteCursor, x: SqliteExecuteBatchResponse): void { + switch (x.tag) { + case "SqliteExecuteBatchOk": { + bare.writeU8(bc, 0) + writeSqliteExecuteBatchOk(bc, x.val) + break + } + case "SqliteErrorResponse": { + bare.writeU8(bc, 1) + writeSqliteErrorResponse(bc, x.val) + break + } + } +} + /** * Core */ @@ -1352,22 +1478,22 @@ export function writeActorName(bc: bare.ByteCursor, x: ActorName): void { writeJson(bc, x.metadata) } -function read15(bc: bare.ByteCursor): string | null { +function read17(bc: bare.ByteCursor): string | null { return bare.readBool(bc) ? bare.readString(bc) : null } -function write15(bc: bare.ByteCursor, x: string | null): void { +function write17(bc: bare.ByteCursor, x: string | null): void { bare.writeBool(bc, x != null) if (x != null) { bare.writeString(bc, x) } } -function read16(bc: bare.ByteCursor): ArrayBuffer | null { +function read18(bc: bare.ByteCursor): ArrayBuffer | null { return bare.readBool(bc) ? bare.readData(bc) : null } -function write16(bc: bare.ByteCursor, x: ArrayBuffer | null): void { +function write18(bc: bare.ByteCursor, x: ArrayBuffer | null): void { bare.writeBool(bc, x != null) if (x != null) { bare.writeData(bc, x) @@ -1384,17 +1510,17 @@ export type ActorConfig = { export function readActorConfig(bc: bare.ByteCursor): ActorConfig { return { name: bare.readString(bc), - key: read15(bc), + key: read17(bc), createTs: bare.readI64(bc), - input: read16(bc), + input: read18(bc), } } export function writeActorConfig(bc: bare.ByteCursor, x: ActorConfig): void { bare.writeString(bc, x.name) - write15(bc, x.key) + write17(bc, x.key) bare.writeI64(bc, x.createTs) - write16(bc, x.input) + write18(bc, x.input) } export type ActorCheckpoint = { @@ -1469,13 +1595,13 @@ export type ActorStateStopped = { export function readActorStateStopped(bc: bare.ByteCursor): ActorStateStopped { return { code: readStopCode(bc), - message: read15(bc), + message: read17(bc), } } export function writeActorStateStopped(bc: bare.ByteCursor, x: ActorStateStopped): void { writeStopCode(bc, x.code) - write15(bc, x.message) + write17(bc, x.message) } export type ActorState = @@ -1635,7 +1761,7 @@ export function writePreloadedKvEntry(bc: bare.ByteCursor, x: PreloadedKvEntry): writeKvMetadata(bc, x.metadata) } -function read17(bc: bare.ByteCursor): readonly PreloadedKvEntry[] { +function read19(bc: bare.ByteCursor): readonly PreloadedKvEntry[] { const len = bare.readUintSafe(bc) if (len === 0) { return [] @@ -1647,7 +1773,7 @@ function read17(bc: bare.ByteCursor): readonly PreloadedKvEntry[] { return result } -function write17(bc: bare.ByteCursor, x: readonly PreloadedKvEntry[]): void { +function write19(bc: bare.ByteCursor, x: readonly PreloadedKvEntry[]): void { bare.writeUintSafe(bc, x.length) for (let i = 0; i < x.length; i++) { writePreloadedKvEntry(bc, x[i]) @@ -1662,14 +1788,14 @@ export type PreloadedKv = { export function readPreloadedKv(bc: bare.ByteCursor): PreloadedKv { return { - entries: read17(bc), + entries: read19(bc), requestedGetKeys: read0(bc), requestedPrefixes: read0(bc), } } export function writePreloadedKv(bc: bare.ByteCursor, x: PreloadedKv): void { - write17(bc, x.entries) + write19(bc, x.entries) write0(bc, x.requestedGetKeys) write0(bc, x.requestedPrefixes) } @@ -1691,7 +1817,7 @@ export function writeHibernatingRequest(bc: bare.ByteCursor, x: HibernatingReque writeRequestId(bc, x.requestId) } -function read18(bc: bare.ByteCursor): readonly HibernatingRequest[] { +function read20(bc: bare.ByteCursor): readonly HibernatingRequest[] { const len = bare.readUintSafe(bc) if (len === 0) { return [] @@ -1703,18 +1829,18 @@ function read18(bc: bare.ByteCursor): readonly HibernatingRequest[] { return result } -function write18(bc: bare.ByteCursor, x: readonly HibernatingRequest[]): void { +function write20(bc: bare.ByteCursor, x: readonly HibernatingRequest[]): void { bare.writeUintSafe(bc, x.length) for (let i = 0; i < x.length; i++) { writeHibernatingRequest(bc, x[i]) } } -function read19(bc: bare.ByteCursor): PreloadedKv | null { +function read21(bc: bare.ByteCursor): PreloadedKv | null { return bare.readBool(bc) ? readPreloadedKv(bc) : null } -function write19(bc: bare.ByteCursor, x: PreloadedKv | null): void { +function write21(bc: bare.ByteCursor, x: PreloadedKv | null): void { bare.writeBool(bc, x != null) if (x != null) { writePreloadedKv(bc, x) @@ -1730,15 +1856,15 @@ export type CommandStartActor = { export function readCommandStartActor(bc: bare.ByteCursor): CommandStartActor { return { config: readActorConfig(bc), - hibernatingRequests: read18(bc), - preloadedKv: read19(bc), + hibernatingRequests: read20(bc), + preloadedKv: read21(bc), } } export function writeCommandStartActor(bc: bare.ByteCursor, x: CommandStartActor): void { writeActorConfig(bc, x.config) - write18(bc, x.hibernatingRequests) - write19(bc, x.preloadedKv) + write20(bc, x.hibernatingRequests) + write21(bc, x.preloadedKv) } export enum StopActorReason { @@ -1945,7 +2071,7 @@ export function writeMessageId(bc: bare.ByteCursor, x: MessageId): void { writeMessageIndex(bc, x.messageIndex) } -function read20(bc: bare.ByteCursor): ReadonlyMap { +function read22(bc: bare.ByteCursor): ReadonlyMap { const len = bare.readUintSafe(bc) const result = new Map() for (let i = 0; i < len; i++) { @@ -1960,7 +2086,7 @@ function read20(bc: bare.ByteCursor): ReadonlyMap { return result } -function write20(bc: bare.ByteCursor, x: ReadonlyMap): void { +function write22(bc: bare.ByteCursor, x: ReadonlyMap): void { bare.writeUintSafe(bc, x.size) for (const kv of x) { bare.writeString(bc, kv[0]) @@ -1985,8 +2111,8 @@ export function readToEnvoyRequestStart(bc: bare.ByteCursor): ToEnvoyRequestStar actorId: readId(bc), method: bare.readString(bc), path: bare.readString(bc), - headers: read20(bc), - body: read16(bc), + headers: read22(bc), + body: read18(bc), stream: bare.readBool(bc), } } @@ -1995,8 +2121,8 @@ export function writeToEnvoyRequestStart(bc: bare.ByteCursor, x: ToEnvoyRequestS writeId(bc, x.actorId) bare.writeString(bc, x.method) bare.writeString(bc, x.path) - write20(bc, x.headers) - write16(bc, x.body) + write22(bc, x.headers) + write18(bc, x.body) bare.writeBool(bc, x.stream) } @@ -2029,16 +2155,16 @@ export type ToRivetResponseStart = { export function readToRivetResponseStart(bc: bare.ByteCursor): ToRivetResponseStart { return { status: bare.readU16(bc), - headers: read20(bc), - body: read16(bc), + headers: read22(bc), + body: read18(bc), stream: bare.readBool(bc), } } export function writeToRivetResponseStart(bc: bare.ByteCursor, x: ToRivetResponseStart): void { bare.writeU16(bc, x.status) - write20(bc, x.headers) - write16(bc, x.body) + write22(bc, x.headers) + write18(bc, x.body) bare.writeBool(bc, x.stream) } @@ -2074,14 +2200,14 @@ export function readToEnvoyWebSocketOpen(bc: bare.ByteCursor): ToEnvoyWebSocketO return { actorId: readId(bc), path: bare.readString(bc), - headers: read20(bc), + headers: read22(bc), } } export function writeToEnvoyWebSocketOpen(bc: bare.ByteCursor, x: ToEnvoyWebSocketOpen): void { writeId(bc, x.actorId) bare.writeString(bc, x.path) - write20(bc, x.headers) + write22(bc, x.headers) } export type ToEnvoyWebSocketMessage = { @@ -2101,11 +2227,11 @@ export function writeToEnvoyWebSocketMessage(bc: bare.ByteCursor, x: ToEnvoyWebS bare.writeBool(bc, x.binary) } -function read21(bc: bare.ByteCursor): u16 | null { +function read23(bc: bare.ByteCursor): u16 | null { return bare.readBool(bc) ? bare.readU16(bc) : null } -function write21(bc: bare.ByteCursor, x: u16 | null): void { +function write23(bc: bare.ByteCursor, x: u16 | null): void { bare.writeBool(bc, x != null) if (x != null) { bare.writeU16(bc, x) @@ -2119,14 +2245,14 @@ export type ToEnvoyWebSocketClose = { export function readToEnvoyWebSocketClose(bc: bare.ByteCursor): ToEnvoyWebSocketClose { return { - code: read21(bc), - reason: read15(bc), + code: read23(bc), + reason: read17(bc), } } export function writeToEnvoyWebSocketClose(bc: bare.ByteCursor, x: ToEnvoyWebSocketClose): void { - write21(bc, x.code) - write15(bc, x.reason) + write23(bc, x.code) + write17(bc, x.reason) } export type ToRivetWebSocketOpen = { @@ -2182,15 +2308,15 @@ export type ToRivetWebSocketClose = { export function readToRivetWebSocketClose(bc: bare.ByteCursor): ToRivetWebSocketClose { return { - code: read21(bc), - reason: read15(bc), + code: read23(bc), + reason: read17(bc), hibernate: bare.readBool(bc), } } export function writeToRivetWebSocketClose(bc: bare.ByteCursor, x: ToRivetWebSocketClose): void { - write21(bc, x.code) - write15(bc, x.reason) + write23(bc, x.code) + write17(bc, x.reason) bare.writeBool(bc, x.hibernate) } @@ -2398,7 +2524,7 @@ export function writeToEnvoyPing(bc: bare.ByteCursor, x: ToEnvoyPing): void { bare.writeI64(bc, x.ts) } -function read22(bc: bare.ByteCursor): ReadonlyMap { +function read24(bc: bare.ByteCursor): ReadonlyMap { const len = bare.readUintSafe(bc) const result = new Map() for (let i = 0; i < len; i++) { @@ -2413,7 +2539,7 @@ function read22(bc: bare.ByteCursor): ReadonlyMap { return result } -function write22(bc: bare.ByteCursor, x: ReadonlyMap): void { +function write24(bc: bare.ByteCursor, x: ReadonlyMap): void { bare.writeUintSafe(bc, x.size) for (const kv of x) { bare.writeString(bc, kv[0]) @@ -2421,22 +2547,22 @@ function write22(bc: bare.ByteCursor, x: ReadonlyMap): void { } } -function read23(bc: bare.ByteCursor): ReadonlyMap | null { - return bare.readBool(bc) ? read22(bc) : null +function read25(bc: bare.ByteCursor): ReadonlyMap | null { + return bare.readBool(bc) ? read24(bc) : null } -function write23(bc: bare.ByteCursor, x: ReadonlyMap | null): void { +function write25(bc: bare.ByteCursor, x: ReadonlyMap | null): void { bare.writeBool(bc, x != null) if (x != null) { - write22(bc, x) + write24(bc, x) } } -function read24(bc: bare.ByteCursor): Json | null { +function read26(bc: bare.ByteCursor): Json | null { return bare.readBool(bc) ? readJson(bc) : null } -function write24(bc: bare.ByteCursor, x: Json | null): void { +function write26(bc: bare.ByteCursor, x: Json | null): void { bare.writeBool(bc, x != null) if (x != null) { writeJson(bc, x) @@ -2453,14 +2579,14 @@ export type ToRivetMetadata = { export function readToRivetMetadata(bc: bare.ByteCursor): ToRivetMetadata { return { - prepopulateActorNames: read23(bc), - metadata: read24(bc), + prepopulateActorNames: read25(bc), + metadata: read26(bc), } } export function writeToRivetMetadata(bc: bare.ByteCursor, x: ToRivetMetadata): void { - write23(bc, x.prepopulateActorNames) - write24(bc, x.metadata) + write25(bc, x.prepopulateActorNames) + write26(bc, x.metadata) } export type ToRivetEvents = readonly EventWrapper[] @@ -2484,7 +2610,7 @@ export function writeToRivetEvents(bc: bare.ByteCursor, x: ToRivetEvents): void } } -function read25(bc: bare.ByteCursor): readonly ActorCheckpoint[] { +function read27(bc: bare.ByteCursor): readonly ActorCheckpoint[] { const len = bare.readUintSafe(bc) if (len === 0) { return [] @@ -2496,7 +2622,7 @@ function read25(bc: bare.ByteCursor): readonly ActorCheckpoint[] { return result } -function write25(bc: bare.ByteCursor, x: readonly ActorCheckpoint[]): void { +function write27(bc: bare.ByteCursor, x: readonly ActorCheckpoint[]): void { bare.writeUintSafe(bc, x.length) for (let i = 0; i < x.length; i++) { writeActorCheckpoint(bc, x[i]) @@ -2509,12 +2635,12 @@ export type ToRivetAckCommands = { export function readToRivetAckCommands(bc: bare.ByteCursor): ToRivetAckCommands { return { - lastCommandCheckpoints: read25(bc), + lastCommandCheckpoints: read27(bc), } } export function writeToRivetAckCommands(bc: bare.ByteCursor, x: ToRivetAckCommands): void { - write25(bc, x.lastCommandCheckpoints) + write27(bc, x.lastCommandCheckpoints) } export type ToRivetStopping = null @@ -2621,6 +2747,23 @@ export function writeToRivetSqliteExecuteRequest(bc: bare.ByteCursor, x: ToRivet writeSqliteExecuteRequest(bc, x.data) } +export type ToRivetSqliteExecuteBatchRequest = { + readonly requestId: u32 + readonly data: SqliteExecuteBatchRequest +} + +export function readToRivetSqliteExecuteBatchRequest(bc: bare.ByteCursor): ToRivetSqliteExecuteBatchRequest { + return { + requestId: bare.readU32(bc), + data: readSqliteExecuteBatchRequest(bc), + } +} + +export function writeToRivetSqliteExecuteBatchRequest(bc: bare.ByteCursor, x: ToRivetSqliteExecuteBatchRequest): void { + bare.writeU32(bc, x.requestId) + writeSqliteExecuteBatchRequest(bc, x.data) +} + export type ToRivet = | { readonly tag: "ToRivetMetadata"; readonly val: ToRivetMetadata } | { readonly tag: "ToRivetEvents"; readonly val: ToRivetEvents } @@ -2633,6 +2776,7 @@ export type ToRivet = | { readonly tag: "ToRivetSqliteCommitRequest"; readonly val: ToRivetSqliteCommitRequest } | { readonly tag: "ToRivetSqliteExecRequest"; readonly val: ToRivetSqliteExecRequest } | { readonly tag: "ToRivetSqliteExecuteRequest"; readonly val: ToRivetSqliteExecuteRequest } + | { readonly tag: "ToRivetSqliteExecuteBatchRequest"; readonly val: ToRivetSqliteExecuteBatchRequest } export function readToRivet(bc: bare.ByteCursor): ToRivet { const offset = bc.offset @@ -2660,6 +2804,8 @@ export function readToRivet(bc: bare.ByteCursor): ToRivet { return { tag: "ToRivetSqliteExecRequest", val: readToRivetSqliteExecRequest(bc) } case 10: return { tag: "ToRivetSqliteExecuteRequest", val: readToRivetSqliteExecuteRequest(bc) } + case 11: + return { tag: "ToRivetSqliteExecuteBatchRequest", val: readToRivetSqliteExecuteBatchRequest(bc) } default: { bc.offset = offset throw new bare.BareError(offset, "invalid tag") @@ -2723,6 +2869,11 @@ export function writeToRivet(bc: bare.ByteCursor, x: ToRivet): void { writeToRivetSqliteExecuteRequest(bc, x.val) break } + case "ToRivetSqliteExecuteBatchRequest": { + bare.writeU8(bc, 11) + writeToRivetSqliteExecuteBatchRequest(bc, x.val) + break + } } } @@ -2809,12 +2960,12 @@ export type ToEnvoyAckEvents = { export function readToEnvoyAckEvents(bc: bare.ByteCursor): ToEnvoyAckEvents { return { - lastEventCheckpoints: read25(bc), + lastEventCheckpoints: read27(bc), } } export function writeToEnvoyAckEvents(bc: bare.ByteCursor, x: ToEnvoyAckEvents): void { - write25(bc, x.lastEventCheckpoints) + write27(bc, x.lastEventCheckpoints) } export type ToEnvoyKvResponse = { @@ -2902,6 +3053,23 @@ export function writeToEnvoySqliteExecuteResponse(bc: bare.ByteCursor, x: ToEnvo writeSqliteExecuteResponse(bc, x.data) } +export type ToEnvoySqliteExecuteBatchResponse = { + readonly requestId: u32 + readonly data: SqliteExecuteBatchResponse +} + +export function readToEnvoySqliteExecuteBatchResponse(bc: bare.ByteCursor): ToEnvoySqliteExecuteBatchResponse { + return { + requestId: bare.readU32(bc), + data: readSqliteExecuteBatchResponse(bc), + } +} + +export function writeToEnvoySqliteExecuteBatchResponse(bc: bare.ByteCursor, x: ToEnvoySqliteExecuteBatchResponse): void { + bare.writeU32(bc, x.requestId) + writeSqliteExecuteBatchResponse(bc, x.data) +} + export type ToEnvoy = | { readonly tag: "ToEnvoyInit"; readonly val: ToEnvoyInit } | { readonly tag: "ToEnvoyCommands"; readonly val: ToEnvoyCommands } @@ -2913,6 +3081,7 @@ export type ToEnvoy = | { readonly tag: "ToEnvoySqliteCommitResponse"; readonly val: ToEnvoySqliteCommitResponse } | { readonly tag: "ToEnvoySqliteExecResponse"; readonly val: ToEnvoySqliteExecResponse } | { readonly tag: "ToEnvoySqliteExecuteResponse"; readonly val: ToEnvoySqliteExecuteResponse } + | { readonly tag: "ToEnvoySqliteExecuteBatchResponse"; readonly val: ToEnvoySqliteExecuteBatchResponse } export function readToEnvoy(bc: bare.ByteCursor): ToEnvoy { const offset = bc.offset @@ -2938,6 +3107,8 @@ export function readToEnvoy(bc: bare.ByteCursor): ToEnvoy { return { tag: "ToEnvoySqliteExecResponse", val: readToEnvoySqliteExecResponse(bc) } case 9: return { tag: "ToEnvoySqliteExecuteResponse", val: readToEnvoySqliteExecuteResponse(bc) } + case 10: + return { tag: "ToEnvoySqliteExecuteBatchResponse", val: readToEnvoySqliteExecuteBatchResponse(bc) } default: { bc.offset = offset throw new bare.BareError(offset, "invalid tag") @@ -2997,6 +3168,11 @@ export function writeToEnvoy(bc: bare.ByteCursor, x: ToEnvoy): void { writeToEnvoySqliteExecuteResponse(bc, x.val) break } + case "ToEnvoySqliteExecuteBatchResponse": { + bare.writeU8(bc, 10) + writeToEnvoySqliteExecuteBatchResponse(bc, x.val) + break + } } } @@ -3269,4 +3445,4 @@ function assert(condition: boolean, message?: string): asserts condition { if (!condition) throw new Error(message ?? "Assertion failed") } -export const VERSION = 5; \ No newline at end of file +export const VERSION = 6; \ No newline at end of file diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs index 60f9e11201..d6c83561e0 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs @@ -116,6 +116,7 @@ pub(crate) struct ActorContextInner { pub(super) schedule_dirty_since_push: AtomicBool, pub(super) schedule_mutation_lock: AsyncMutex<()>, pub(super) schedule_running: SccHashSet, + pub(super) schedule_history_insert_count: AtomicUsize, pub(super) max_schedules: u32, #[cfg(any(test, feature = "test-support"))] pub(super) schedule_now_override: AtomicI64, @@ -331,6 +332,7 @@ impl ActorContext { schedule_dirty_since_push: AtomicBool::new(true), schedule_mutation_lock: AsyncMutex::new(()), schedule_running: SccHashSet::new(), + schedule_history_insert_count: AtomicUsize::new(0), max_schedules, #[cfg(any(test, feature = "test-support"))] schedule_now_override: AtomicI64::new(i64::MIN), diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs index dac9e3f8f1..dd87bae2d8 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs @@ -5,6 +5,8 @@ use crate::sqlite::{BindParam, ColumnValue, SqliteBatchStatement, SqliteDb}; pub(crate) const INTERNAL_SCHEMA_VERSION: i64 = 6; const SCHEMA_VERSION_KEY: &str = "schema_version"; +pub(crate) const READ_SCHEMA_VERSION_SQL: &str = + "SELECT value FROM _rivet_meta WHERE key = ?"; // `_rivet_meta` is the bootstrap root created before the numbered migrations. // `schema_version` cannot live in a table created by those migrations, and @@ -12,7 +14,7 @@ const SCHEMA_VERSION_KEY: &str = "schema_version"; // interrupted imports can be detected and retried. This is not a general- // purpose runtime KV store; its text accessors are migration bookkeeping only. // W[bootstrap + import bookkeeping only | point upsert | <100 B | 1-page map] -const CREATE_META_TABLE: &str = r#" +pub(crate) const CREATE_META_TABLE: &str = r#" CREATE TABLE IF NOT EXISTS _rivet_meta ( key TEXT PRIMARY KEY, value BLOB NOT NULL @@ -25,7 +27,7 @@ CREATE TABLE IF NOT EXISTS _rivet_meta ( // across runtime releases. Rewriting these entries in place is safe only while // no internal schema version has shipped; after release, all changes must be // appended as new migrations and INTERNAL_SCHEMA_VERSION must advance. -const MIGRATIONS: &[&[&str]] = &[ +pub(crate) const MIGRATIONS: &[&[&str]] = &[ &[ // W[queue_next_id per enqueue; alarm per head-change; token once | point UPDATE of one column | <100 B | single-row: all runtime singletons on one leaf] r#" @@ -97,6 +99,11 @@ CREATE INDEX _rivet_schedule_history_schedule r#" CREATE INDEX _rivet_schedule_history_fired_at ON _rivet_schedule_history (fired_at DESC, id DESC) +"#, + r#" +CREATE INDEX _rivet_schedule_history_running + ON _rivet_schedule_history (result) + WHERE result = 0 "#, ], &[ @@ -214,7 +221,7 @@ fn migration_statements(from_version: i64, to_version: i64) -> Result Result { let result = db .query( - "SELECT value FROM _rivet_meta WHERE key = ?", + READ_SCHEMA_VERSION_SQL, Some(vec![BindParam::Text(SCHEMA_VERSION_KEY.to_owned())]), ) .await diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs index 51cf7c3fb1..9ce1441974 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs @@ -31,6 +31,59 @@ pub(crate) const KV_TX_MAX_PAYLOAD_BYTES: usize = 512 * 1024; pub(crate) const KV_TX_MAX_ROWS: usize = 128; const CONNECTION_DESTINATION_ROWS_PER_RECORD: usize = 2; +pub(crate) const LOAD_ACTOR_SNAPSHOT_SQL: &str = "SELECT a.has_initialized, a.input, s.state FROM _rivet_actor a JOIN _rivet_actor_state s ON s.id = a.id WHERE a.id = 1"; +pub(crate) const LOAD_CONNECTIONS_SQL: &str = "SELECT c.conn_id, c.parameters, s.state, s.subscriptions, c.gateway_id, c.request_id, s.server_message_index, s.client_message_index, c.request_path, c.request_headers FROM _rivet_conns c JOIN _rivet_conn_state s ON s.conn_id = c.conn_id ORDER BY c.conn_id"; +pub(crate) const RESET_SCHEDULES_FOR_LEGACY_IMPORT_SQL: &str = + "DELETE FROM _rivet_schedule_events"; +pub(crate) const LOAD_QUEUE_NEXT_ID_SQL: &str = + "SELECT queue_next_id FROM _rivet_runtime WHERE id = 1"; +pub(crate) const LOAD_QUEUE_STATS_SQL: &str = "SELECT COUNT(*), MAX(id) FROM _rivet_queue"; +pub(crate) const LOAD_QUEUE_MESSAGES_SQL: &str = + "SELECT id, name, body, created_at FROM _rivet_queue ORDER BY id"; +pub(crate) const DELETE_QUEUE_MESSAGE_SQL: &str = "DELETE FROM _rivet_queue WHERE id = ?"; +pub(crate) const RESET_QUEUE_SQL: &str = "DELETE FROM _rivet_queue"; +pub(crate) const DELETE_USER_KV_SQL: &str = "DELETE FROM _rivet_user_kv WHERE key = ?"; +pub(crate) const DELETE_USER_KV_RANGE_SQL: &str = + "DELETE FROM _rivet_user_kv WHERE key >= ? AND key < ?"; +pub(crate) const DELETE_CONN_STATE_SQL: &str = + "DELETE FROM _rivet_conn_state WHERE conn_id = ?"; +pub(crate) const DELETE_CONN_SQL: &str = "DELETE FROM _rivet_conns WHERE conn_id = ?"; +pub(crate) const LOAD_LAST_PUSHED_ALARM_SQL: &str = + "SELECT last_pushed_alarm FROM _rivet_runtime WHERE id = 1"; +pub(crate) const LOAD_INSPECTOR_TOKEN_SQL: &str = + "SELECT inspector_token FROM _rivet_runtime WHERE id = 1"; +pub(crate) const LOAD_META_TEXT_SQL: &str = "SELECT value FROM _rivet_meta WHERE key = ?"; + +pub(crate) fn user_kv_batch_get_sql(key_count: usize) -> String { + let placeholders = std::iter::repeat_n("?", key_count) + .collect::>() + .join(", "); + format!("SELECT key, value FROM _rivet_user_kv WHERE key IN ({placeholders})") +} + +pub(crate) fn user_kv_list_sql(where_clause: &str, reverse: bool, limited: bool) -> String { + let order = if reverse { "DESC" } else { "ASC" }; + let limit_clause = if limited { " LIMIT ?" } else { "" }; + format!( + "SELECT key, value FROM _rivet_user_kv {where_clause} ORDER BY key {order}{limit_clause}" + ) +} + +pub(crate) fn clear_table_select_sql( + table: &str, + key_column: &str, + size_expression: &str, +) -> String { + format!( + "SELECT {key_column}, {size_expression} FROM {table} ORDER BY {key_column} LIMIT {}", + KV_TX_MAX_ROWS + ) +} + +pub(crate) fn clear_table_delete_sql(table: &str, key_column: &str) -> String { + format!("DELETE FROM {table} WHERE {key_column} = ?") +} + /// Splits `entries` into contiguous chunks that each stay within the /// per-transaction row and payload budgets. A single oversized entry gets its /// own chunk; per-entry limits are enforced by the callers. @@ -69,10 +122,7 @@ pub(crate) struct InternalActorSnapshot { pub(crate) async fn load_actor_snapshot(db: &SqliteDb) -> Result> { let actor_rows = db - .query( - "SELECT a.has_initialized, a.input, s.state FROM _rivet_actor a JOIN _rivet_actor_state s ON s.id = a.id WHERE a.id = 1", - None, - ) + .query(LOAD_ACTOR_SNAPSHOT_SQL, None) .await .context("load internal actor rows")?; @@ -137,7 +187,7 @@ pub(crate) async fn import_legacy_actor_snapshot( params: Some(vec![BindParam::Blob(actor.state.clone())]), }, SqliteBatchStatement { - sql: "DELETE FROM _rivet_schedule_events".to_owned(), + sql: RESET_SCHEDULES_FOR_LEGACY_IMPORT_SQL.to_owned(), params: None, }, ]; @@ -302,10 +352,7 @@ async fn persist_connection_snapshot_chunk( pub(crate) async fn load_connections(db: &SqliteDb) -> Result> { let result = db - .query( - "SELECT c.conn_id, c.parameters, s.state, s.subscriptions, c.gateway_id, c.request_id, s.server_message_index, s.client_message_index, c.request_path, c.request_headers FROM _rivet_conns c JOIN _rivet_conn_state s ON s.conn_id = c.conn_id ORDER BY c.conn_id", - None, - ) + .query(LOAD_CONNECTIONS_SQL, None) .await .context("load internal connection rows")?; @@ -337,10 +384,7 @@ pub(crate) async fn load_connections(db: &SqliteDb) -> Result Result { let runtime = db - .query( - "SELECT queue_next_id FROM _rivet_runtime WHERE id = 1", - None, - ) + .query(LOAD_QUEUE_NEXT_ID_SQL, None) .await .context("load internal queue next id")?; let stored_next_id = match runtime.rows.first() { @@ -350,7 +394,7 @@ pub(crate) async fn load_queue_metadata(db: &SqliteDb) -> Result }; let stats = db - .query("SELECT COUNT(*), MAX(id) FROM _rivet_queue", None) + .query(LOAD_QUEUE_STATS_SQL, None) .await .context("load internal queue stats")?; let Some(row) = stats.rows.first() else { @@ -475,10 +519,7 @@ pub(crate) async fn persist_queue_next_id(db: &SqliteDb, next_id: u64) -> Result pub(crate) async fn load_queue_messages(db: &SqliteDb) -> Result> { let result = db - .query( - "SELECT id, name, body, created_at FROM _rivet_queue ORDER BY id", - None, - ) + .query(LOAD_QUEUE_MESSAGES_SQL, None) .await .context("load internal queue messages")?; @@ -511,7 +552,7 @@ pub(crate) async fn delete_queue_messages(db: &SqliteDb, ids: &[u64]) -> Result< let mut statements = Vec::with_capacity(ids.len()); for id in ids { statements.push(SqliteBatchStatement { - sql: "DELETE FROM _rivet_queue WHERE id = ?".to_owned(), + sql: DELETE_QUEUE_MESSAGE_SQL.to_owned(), params: Some(vec![BindParam::Integer( i64::try_from(*id).context("queue message id exceeds sqlite integer range")?, )]), @@ -524,7 +565,7 @@ pub(crate) async fn delete_queue_messages(db: &SqliteDb, ids: &[u64]) -> Result< } pub(crate) async fn reset_queue(db: &SqliteDb) -> Result<()> { - db.execute("DELETE FROM _rivet_queue", None) + db.execute(RESET_QUEUE_SQL, None) .await .context("reset internal queue")?; Ok(()) @@ -542,12 +583,9 @@ pub(crate) async fn user_kv_batch_get( ) -> Result>>> { let mut values_by_key = HashMap::with_capacity(keys.len()); for keys in keys.chunks(USER_KV_BATCH_GET_MAX_KEYS) { - let placeholders = std::iter::repeat_n("?", keys.len()) - .collect::>() - .join(", "); let result = db .query( - format!("SELECT key, value FROM _rivet_user_kv WHERE key IN ({placeholders})"), + user_kv_batch_get_sql(keys.len()), Some( keys.iter() .map(|key| BindParam::Blob((*key).to_vec())) @@ -615,7 +653,7 @@ pub(crate) async fn user_kv_batch_delete(db: &SqliteDb, keys: &[&[u8]]) -> Resul let statements = keys .iter() .map(|key| SqliteBatchStatement { - sql: "DELETE FROM _rivet_user_kv WHERE key = ?".to_owned(), + sql: DELETE_USER_KV_SQL.to_owned(), params: Some(vec![BindParam::Blob((*key).to_vec())]), }) .collect::>(); @@ -630,7 +668,7 @@ pub(crate) async fn user_kv_batch_delete(db: &SqliteDb, keys: &[&[u8]]) -> Resul pub(crate) async fn user_kv_delete_range(db: &SqliteDb, start: &[u8], end: &[u8]) -> Result<()> { db.execute( - "DELETE FROM _rivet_user_kv WHERE key >= ? AND key < ?", + DELETE_USER_KV_RANGE_SQL, Some(vec![ BindParam::Blob(start.to_vec()), BindParam::Blob(end.to_vec()), @@ -765,26 +803,23 @@ fn bind_param_payload_len(param: &BindParam) -> usize { async fn user_kv_list_where( db: &SqliteDb, where_clause: &str, - mut params: Vec, + params: Vec, opts: ListOpts, ) -> Result, Vec)>> { - let order = if opts.reverse { "DESC" } else { "ASC" }; - let limit_clause = if let Some(limit) = opts.limit { + let mut params = params; + if let Some(limit) = opts.limit { params.push(BindParam::Integer(i64::from(limit))); - " LIMIT ?" - } else { - "" - }; - let sql = format!( - "SELECT key, value FROM _rivet_user_kv {where_clause} ORDER BY key {order}{limit_clause}" - ); + } + let sql = user_kv_list_sql(where_clause, opts.reverse, opts.limit.is_some()); let result = db - .query(&sql, (!params.is_empty()).then_some(params)) + .query(&sql, Some(params)) .await .context("list user kv values from sqlite")?; - result - .rows - .iter() + decode_user_kv_rows(&result.rows) +} + +fn decode_user_kv_rows(rows: &[Vec]) -> Result, Vec)>> { + rows.iter() .map(|row| { Ok(( read_blob(row, 0, "user kv key")?, @@ -847,21 +882,18 @@ fn push_connection_statements( fn push_delete_connection_statements(statements: &mut Vec, conn_id: &str) { statements.push(SqliteBatchStatement { - sql: "DELETE FROM _rivet_conn_state WHERE conn_id = ?".to_owned(), + sql: DELETE_CONN_STATE_SQL.to_owned(), params: Some(vec![BindParam::Text(conn_id.to_owned())]), }); statements.push(SqliteBatchStatement { - sql: "DELETE FROM _rivet_conns WHERE conn_id = ?".to_owned(), + sql: DELETE_CONN_SQL.to_owned(), params: Some(vec![BindParam::Text(conn_id.to_owned())]), }); } pub(crate) async fn load_last_pushed_alarm(db: &SqliteDb) -> Result> { let result = db - .query( - "SELECT last_pushed_alarm FROM _rivet_runtime WHERE id = 1", - None, - ) + .query(LOAD_LAST_PUSHED_ALARM_SQL, None) .await .context("load internal last pushed alarm")?; let Some(row) = result.rows.first() else { @@ -886,10 +918,7 @@ pub(crate) async fn persist_last_pushed_alarm(db: &SqliteDb, alarm_ts: Option Result> { let result = db - .query( - "SELECT inspector_token FROM _rivet_runtime WHERE id = 1", - None, - ) + .query(LOAD_INSPECTOR_TOKEN_SQL, None) .await .context("load internal inspector token")?; let Some(row) = result.rows.first() else { @@ -917,7 +946,7 @@ pub(crate) async fn persist_inspector_token(db: &SqliteDb, token: &str) -> Resul pub(crate) async fn load_meta_text(db: &SqliteDb, key: &str) -> Result> { let result = db .query( - "SELECT value FROM _rivet_meta WHERE key = ?", + LOAD_META_TEXT_SQL, Some(vec![BindParam::Text(key.to_owned())]), ) .await @@ -994,10 +1023,7 @@ async fn clear_table_bounded( loop { let rows = db .query( - format!( - "SELECT {key_column}, {size_expression} FROM {table} ORDER BY {key_column} LIMIT {}", - KV_TX_MAX_ROWS - ), + clear_table_select_sql(table, key_column, size_expression), None, ) .await?; @@ -1035,7 +1061,7 @@ async fn clear_table_bounded( } payload_bytes = payload_bytes.saturating_add(row_bytes); statements.push(SqliteBatchStatement { - sql: format!("DELETE FROM {table} WHERE {key_column} = ?"), + sql: clear_table_delete_sql(table, key_column), params: Some(vec![key]), }); } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs index e6ff351010..83be0ca298 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs @@ -22,6 +22,9 @@ pub mod queue; pub mod schedule; pub mod sleep; pub mod sqlite; +#[cfg(test)] +#[path = "../../tests/sql_efficiency.rs"] +mod sql_efficiency; pub mod state; pub mod task; pub mod task_types; diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs index 170a34ec9f..f6ae562d5d 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs @@ -32,6 +32,45 @@ pub const MAX_HISTORY: i64 = 1_000; pub const MAX_ACTOR_HISTORY: i64 = 10_000; pub const MIN_INTERVAL_MS: i64 = 5_000; const DEFAULT_HISTORY_LIMIT: i64 = 20; +const CLAIM_ONE_SHOT_BATCH_SIZE: usize = 128; +pub(crate) const GLOBAL_HISTORY_PRUNE_INTERVAL: usize = 100; +pub(crate) const GLOBAL_HISTORY_RETAINED_ROWS: i64 = + MAX_ACTOR_HISTORY - GLOBAL_HISTORY_PRUNE_INTERVAL as i64; + +pub(crate) const CANCEL_SCHEDULE_SQL: &str = + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?"; +pub(crate) const GET_SCHEDULED_EVENT_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?"; +pub(crate) const LIST_SCHEDULED_EVENTS_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind = ? ORDER BY trigger_at, event_id"; +pub(crate) const DELETE_CRON_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND EXISTS (SELECT 1 FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?)"; +pub(crate) const DELETE_CRON_SQL: &str = + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?"; +pub(crate) const DELETE_CRON_IF_ACTION_SQL: &str = + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ? AND action = ?"; +pub(crate) const LIST_CRONS_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind != ? ORDER BY trigger_at, event_id"; +pub(crate) const CRON_HISTORY_SQL: &str = "SELECT action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?"; +pub(crate) const LOAD_SCHEDULE_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ?"; +pub(crate) const COUNT_SCHEDULES_SQL: &str = "SELECT COUNT(*) FROM _rivet_schedule_events"; +pub(crate) const TAKE_DUE_SCHEDULES_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE trigger_at <= ? ORDER BY trigger_at, event_id"; +pub(crate) fn claim_one_shots_sql(event_count: usize) -> String { + let placeholders = std::iter::repeat_n("(?, ?)", event_count) + .collect::>() + .join(", "); + format!( + "DELETE FROM _rivet_schedule_events WHERE kind = ? AND (event_id, trigger_at) IN ({placeholders})" + ) +} +pub(crate) const ADVANCE_SKIPPED_SCHEDULE_SQL: &str = + "UPDATE _rivet_schedule_events SET trigger_at = ? WHERE event_id = ?"; +pub(crate) const ADVANCE_SCHEDULE_SQL: &str = + "UPDATE _rivet_schedule_events SET trigger_at = ?, last_started_at = ? WHERE event_id = ?"; +pub(crate) const FINISH_HISTORY_SQL: &str = "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE id = ? AND result = ?"; +pub(crate) const RECOVER_HISTORY_SQL: &str = "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE result = 0"; +pub(crate) const NEXT_FUTURE_SCHEDULE_SQL: &str = + "SELECT MIN(trigger_at) FROM _rivet_schedule_events WHERE trigger_at > ?"; +pub(crate) const NEXT_SCHEDULE_SQL: &str = + "SELECT MIN(trigger_at) FROM _rivet_schedule_events"; +pub(crate) const PRUNE_SCHEDULE_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND id NOT IN (SELECT id FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?)"; +pub(crate) const PRUNE_GLOBAL_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE id IN (SELECT id FROM _rivet_schedule_history ORDER BY fired_at DESC, id DESC LIMIT -1 OFFSET ?)"; pub(super) type InternalKeepAwakeCallback = Arc>) -> BoxFuture<'static, Result<()>> + Send + Sync>; @@ -214,7 +253,7 @@ impl ActorContext { let result = self .sql() .execute( - "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?", + CANCEL_SCHEDULE_SQL, Some(vec![ BindParam::Text(event_id.to_owned()), BindParam::Integer(ScheduleKind::At.as_i64()), @@ -235,7 +274,7 @@ impl ActorContext { let result = self .sql() .query( - "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?", + GET_SCHEDULED_EVENT_SQL, Some(vec![ BindParam::Text(event_id.to_owned()), BindParam::Integer(ScheduleKind::At.as_i64()), @@ -262,7 +301,7 @@ impl ActorContext { let result = self .sql() .query( - "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind = ? ORDER BY trigger_at, event_id", + LIST_SCHEDULED_EVENTS_SQL, Some(vec![BindParam::Integer(ScheduleKind::At.as_i64())]), ) .await @@ -419,7 +458,7 @@ impl ActorContext { .sql() .execute_batch(vec![ SqliteBatchStatement { - sql: "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND EXISTS (SELECT 1 FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?)".to_owned(), + sql: DELETE_CRON_HISTORY_SQL.to_owned(), params: Some(vec![ BindParam::Text(event_id.clone()), BindParam::Text(event_id.clone()), @@ -427,8 +466,7 @@ impl ActorContext { ]), }, SqliteBatchStatement { - sql: "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?" - .to_owned(), + sql: DELETE_CRON_SQL.to_owned(), params: Some(vec![ BindParam::Text(event_id), BindParam::Integer(ScheduleKind::At.as_i64()), @@ -454,7 +492,7 @@ impl ActorContext { let result = self .sql() .execute( - "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ? AND action = ?", + DELETE_CRON_IF_ACTION_SQL, Some(vec![ BindParam::Text(cron_event_id(name)), BindParam::Integer(ScheduleKind::At.as_i64()), @@ -484,7 +522,7 @@ impl ActorContext { let result = self .sql() .query( - "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind != ? ORDER BY trigger_at, event_id", + LIST_CRONS_SQL, Some(vec![BindParam::Integer(ScheduleKind::At.as_i64())]), ) .await @@ -510,7 +548,7 @@ impl ActorContext { let result = self .sql() .query( - "SELECT action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?", + CRON_HISTORY_SQL, Some(vec![ BindParam::Text(cron_event_id(name)), BindParam::Integer(limit), @@ -525,7 +563,7 @@ impl ActorContext { let result = self .sql() .query( - "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ?", + LOAD_SCHEDULE_SQL, Some(vec![BindParam::Text(event_id.to_owned())]), ) .await @@ -543,7 +581,7 @@ impl ActorContext { } let result = self .sql() - .query("SELECT COUNT(*) FROM _rivet_schedule_events", None) + .query(COUNT_SCHEDULES_SQL, None) .await .context("count pending schedules")?; let count = result @@ -574,25 +612,43 @@ impl ActorContext { let result = self .sql() .query( - "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE trigger_at <= ? ORDER BY trigger_at, event_id", + TAKE_DUE_SCHEDULES_SQL, Some(vec![BindParam::Integer(now_ms)]), ) .await .context("load due schedules")?; + let due_schedules = result + .rows + .iter() + .map(|row| read_stored_schedule(row)) + .collect::>>()?; + let claim_statements = due_schedules + .iter() + .filter(|event| event.kind == ScheduleKind::At) + .collect::>() + .chunks(CLAIM_ONE_SHOT_BATCH_SIZE) + .map(|events| { + let mut params = Vec::with_capacity(events.len() * 2 + 1); + params.push(BindParam::Integer(ScheduleKind::At.as_i64())); + for event in events { + params.push(BindParam::Text(event.event_id.clone())); + params.push(BindParam::Integer(event.trigger_at)); + } + SqliteBatchStatement { + sql: claim_one_shots_sql(events.len()), + params: Some(params), + } + }) + .collect::>(); + if !claim_statements.is_empty() { + self.sql() + .execute_batch(claim_statements) + .await + .context("claim due one-shot schedules")?; + } let mut dispatches = Vec::new(); - for row in &result.rows { - let event = read_stored_schedule(row)?; + for event in due_schedules { if event.kind == ScheduleKind::At { - self.sql() - .execute( - "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND trigger_at = ?", - Some(vec![ - BindParam::Text(event.event_id.clone()), - BindParam::Integer(event.trigger_at), - ]), - ) - .await - .context("claim due one-shot schedule")?; dispatches.push(DueScheduleDispatch { event_id: event.event_id.clone(), action: event.action, @@ -617,9 +673,9 @@ impl ActorContext { .is_err(); let mut statements = vec![SqliteBatchStatement { sql: if is_running { - "UPDATE _rivet_schedule_events SET trigger_at = ? WHERE event_id = ?".to_owned() + ADVANCE_SKIPPED_SCHEDULE_SQL.to_owned() } else { - "UPDATE _rivet_schedule_events SET trigger_at = ?, last_started_at = ? WHERE event_id = ?".to_owned() + ADVANCE_SCHEDULE_SQL.to_owned() }, params: Some(if is_running { vec![ @@ -639,8 +695,14 @@ impl ActorContext { } else { HISTORY_RUNNING }; - let history_index = - append_history_statements(&mut statements, &event, now_ms, history_result)?; + let prune_global_history = event.max_history > 0 && self.should_prune_global_history(); + let history_index = append_history_statements( + &mut statements, + &event, + now_ms, + history_result, + prune_global_history, + )?; let results = match self.sql().execute_batch(statements).await { Ok(results) => results, Err(error) => { @@ -703,7 +765,7 @@ impl ActorContext { match self .sql() .execute( - "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE id = ? AND result = ?", + FINISH_HISTORY_SQL, Some(vec![ BindParam::Integer(finished_at), BindParam::Integer(result), @@ -733,7 +795,7 @@ impl ActorContext { }; self.sql() .execute( - "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE result = ?", + RECOVER_HISTORY_SQL, Some(vec![ BindParam::Integer(self.schedule_now_timestamp_ms()), BindParam::Integer(HISTORY_ERROR), @@ -741,7 +803,6 @@ impl ActorContext { BindParam::Text(error.code), BindParam::Text(error.message), BindParam::Null, - BindParam::Integer(HISTORY_RUNNING), ]), ) .await @@ -752,12 +813,20 @@ impl ActorContext { async fn prune_schedule_history(&self, event_id: &str, max_history: i64) -> Result<()> { self.sql() - .execute_batch(history_prune_statements(event_id, max_history)) + .execute_batch(history_prune_statements(event_id, max_history, false)) .await .context("prune schedule history")?; Ok(()) } + fn should_prune_global_history(&self) -> bool { + self.0 + .schedule_history_insert_count + .fetch_add(1, Ordering::Relaxed) + % GLOBAL_HISTORY_PRUNE_INTERVAL + == 0 + } + fn mark_schedule_dirty(&self) { self.0 .schedule_dirty_since_push @@ -767,11 +836,11 @@ impl ActorContext { async fn next_schedule_timestamp(&self, future_only: bool) -> Result> { let (sql, params) = if future_only { ( - "SELECT MIN(trigger_at) FROM _rivet_schedule_events WHERE trigger_at > ?", + NEXT_FUTURE_SCHEDULE_SQL, Some(vec![BindParam::Integer(self.schedule_now_timestamp_ms())]), ) } else { - ("SELECT MIN(trigger_at) FROM _rivet_schedule_events", None) + (NEXT_SCHEDULE_SQL, None) }; let result = self.sql().query(sql, params).await?; match result.rows.first().and_then(|row| row.first()) { @@ -1116,6 +1185,7 @@ fn append_history_statements( event: &StoredSchedule, now_ms: i64, result: i64, + prune_global_history: bool, ) -> Result> { if event.max_history == 0 { return Ok(None); @@ -1140,25 +1210,34 @@ fn append_history_statements( BindParam::Null, ]), }); - statements.extend(history_prune_statements(&event.event_id, event.max_history)); + statements.extend(history_prune_statements( + &event.event_id, + event.max_history, + prune_global_history, + )); Ok(Some(index)) } -fn history_prune_statements(event_id: &str, max_history: i64) -> Vec { - vec![ - SqliteBatchStatement { - sql: "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND id NOT IN (SELECT id FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?)".to_owned(), - params: Some(vec![ - BindParam::Text(event_id.to_owned()), - BindParam::Text(event_id.to_owned()), - BindParam::Integer(max_history), - ]), - }, - SqliteBatchStatement { - sql: "DELETE FROM _rivet_schedule_history WHERE id IN (SELECT id FROM _rivet_schedule_history ORDER BY fired_at ASC, id ASC LIMIT MAX((SELECT COUNT(*) FROM _rivet_schedule_history) - ?, 0))".to_owned(), - params: Some(vec![BindParam::Integer(MAX_ACTOR_HISTORY)]), - }, - ] +fn history_prune_statements( + event_id: &str, + max_history: i64, + prune_global_history: bool, +) -> Vec { + let mut statements = vec![SqliteBatchStatement { + sql: PRUNE_SCHEDULE_HISTORY_SQL.to_owned(), + params: Some(vec![ + BindParam::Text(event_id.to_owned()), + BindParam::Text(event_id.to_owned()), + BindParam::Integer(max_history), + ]), + }]; + if prune_global_history { + statements.push(SqliteBatchStatement { + sql: PRUNE_GLOBAL_HISTORY_SQL.to_owned(), + params: Some(vec![BindParam::Integer(GLOBAL_HISTORY_RETAINED_ROWS)]), + }); + } + statements } fn stored_to_cron_info(event: StoredSchedule) -> Result { diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/mod.rs index 68370976e3..d5cb69876c 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/sqlite/mod.rs @@ -449,31 +449,38 @@ impl SqliteDb { .iter() .map(|statement| bind_param_count(&statement.params)) .sum(); - let result = async { - let transaction = self.begin_transaction(None).await?; - let mut results = Vec::with_capacity(statements.len()); - for statement in statements { - match transaction.execute(statement.sql, statement.params).await { - Ok(result) => results.push(result), - Err(error) => { - return match transaction.rollback().await { - Ok(()) => Err(error.context("execute sqlite batch statement")), - Err(rollback_error) => { - Err(error.context("execute sqlite batch statement").context( - rollback_error.context("rollback sqlite batch transaction"), - )) - } - }; + let result = if self.backend == SqliteBackend::RemoteEnvoy { + match self.begin_regular_operation().await { + Ok(_guard) => self.remote_execute_batch(statements).await, + Err(error) => Err(error), + } + } else { + async { + let transaction = self.begin_transaction(None).await?; + let mut results = Vec::with_capacity(statements.len()); + for statement in statements { + match transaction.execute(statement.sql, statement.params).await { + Ok(result) => results.push(result), + Err(error) => { + return match transaction.rollback().await { + Ok(()) => Err(error.context("execute sqlite batch statement")), + Err(rollback_error) => Err(error + .context("execute sqlite batch statement") + .context( + rollback_error.context("rollback sqlite batch transaction"), + )), + }; + } } } + transaction + .commit() + .await + .context("commit sqlite batch transaction")?; + Ok(results) } - transaction - .commit() - .await - .context("commit sqlite batch transaction")?; - Ok(results) - } - .await; + .await + }; match result { Ok(results) => Ok(results), @@ -800,6 +807,40 @@ impl SqliteDb { } } + async fn remote_execute_batch( + &self, + statements: Vec, + ) -> Result> { + let config = self.remote_config()?; + let response = config + .handle + .remote_sqlite_execute_batch(protocol::SqliteExecuteBatchRequest { + namespace_id: config.namespace_id, + actor_id: config.actor_id, + generation: config.generation, + statements: statements + .into_iter() + .map(|statement| protocol::SqliteBatchStatement { + sql: statement.sql, + params: statement.params.map(protocol_bind_params), + }) + .collect(), + }) + .await + .map_err(remote_request_error)?; + + match response { + protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk(ok) => Ok(ok + .results + .into_iter() + .map(execute_result_from_protocol) + .collect()), + protocol::SqliteExecuteBatchResponse::SqliteErrorResponse(error) => { + Err(self.remote_sqlite_error_response(error)) + } + } + } + async fn remote_execute_with_session( &self, sql: String, diff --git a/rivetkit-rust/packages/rivetkit-core/src/testing.rs b/rivetkit-rust/packages/rivetkit-core/src/testing.rs index df8095ff16..048364a3f2 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/testing.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/testing.rs @@ -220,12 +220,17 @@ fn spawn_remote_sqlite(mut receiver: mpsc::UnboundedReceiver) { else { continue; }; - let RemoteSqliteRequest::Execute(request) = request else { - continue; + let response = match request { + RemoteSqliteRequest::Execute(request) => { + RemoteSqliteResponse::Execute(execute_sqlite(&conn, request)) + } + RemoteSqliteRequest::ExecuteBatch(request) => { + RemoteSqliteResponse::ExecuteBatch(execute_sqlite_batch(&conn, request)) + } + RemoteSqliteRequest::Exec(_) => continue, }; - let response = execute_sqlite(&conn, request); let _ = response_tx.send(Ok(RemoteSqliteResponseEnvelope { - response: RemoteSqliteResponse::Execute(response), + response, session: 1, })); } @@ -233,6 +238,49 @@ fn spawn_remote_sqlite(mut receiver: mpsc::UnboundedReceiver) { }); } +fn execute_sqlite_batch( + conn: &rusqlite::Connection, + request: protocol::SqliteExecuteBatchRequest, +) -> protocol::SqliteExecuteBatchResponse { + let result = (|| { + conn.execute_batch("BEGIN")?; + let mut results = Vec::with_capacity(request.statements.len()); + for statement in request.statements { + let result = execute_sqlite_inner( + conn, + protocol::SqliteExecuteRequest { + namespace_id: request.namespace_id.clone(), + actor_id: request.actor_id.clone(), + generation: request.generation, + sql: statement.sql, + params: statement.params, + }, + ); + match result { + Ok(result) => results.push(result), + Err(error) => { + let _ = conn.execute_batch("ROLLBACK"); + return Err(error); + } + } + } + conn.execute_batch("COMMIT")?; + Ok(results) + })(); + match result { + Ok(results) => protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk( + protocol::SqliteExecuteBatchOk { results }, + ), + Err(error) => protocol::SqliteExecuteBatchResponse::SqliteErrorResponse( + protocol::SqliteErrorResponse { + group: "sqlite".to_owned(), + code: "internal_error".to_owned(), + message: error.to_string(), + }, + ), + } +} + fn execute_sqlite( conn: &rusqlite::Connection, request: protocol::SqliteExecuteRequest, @@ -376,6 +424,9 @@ CREATE INDEX _rivet_schedule_history_schedule ON _rivet_schedule_history (schedule_id, fired_at DESC, id DESC); CREATE INDEX _rivet_schedule_history_fired_at ON _rivet_schedule_history (fired_at DESC, id DESC); +CREATE INDEX _rivet_schedule_history_running + ON _rivet_schedule_history (result) + WHERE result = 0; CREATE TABLE _rivet_conns ( conn_id TEXT PRIMARY KEY, parameters BLOB NOT NULL, diff --git a/rivetkit-rust/packages/rivetkit-core/tests/context.rs b/rivetkit-rust/packages/rivetkit-core/tests/context.rs index 4607871cbe..de0c475610 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/context.rs @@ -229,23 +229,41 @@ fn spawn_test_remote_sqlite_on( else { continue; }; - let TestRemoteSqliteRequest::Execute(request) = request else { - continue; + let response = match request { + TestRemoteSqliteRequest::Execute(request) => { + if let Some(gate) = write_gate + .as_ref() + .filter(|gate| request.sql.starts_with(gate.sql_prefix)) + { + let _ = gate.entered_tx.send(()); + gate.release + .acquire() + .await + .expect("write gate semaphore should stay open") + .forget(); + } + TestRemoteSqliteResponse::Execute(execute_test_sqlite(&conn, request)) + } + TestRemoteSqliteRequest::ExecuteBatch(request) => { + if let Some(gate) = write_gate.as_ref().filter(|gate| { + request + .statements + .iter() + .any(|statement| statement.sql.starts_with(gate.sql_prefix)) + }) { + let _ = gate.entered_tx.send(()); + gate.release + .acquire() + .await + .expect("write gate semaphore should stay open") + .forget(); + } + TestRemoteSqliteResponse::ExecuteBatch(execute_test_sqlite_batch(&conn, request)) + } + TestRemoteSqliteRequest::Exec(_) => continue, }; - if let Some(gate) = write_gate - .as_ref() - .filter(|gate| request.sql.starts_with(gate.sql_prefix)) - { - let _ = gate.entered_tx.send(()); - gate.release - .acquire() - .await - .expect("write gate semaphore should stay open") - .forget(); - } - let response = execute_test_sqlite(&conn, request); let _ = response_tx.send(Ok(TestRemoteSqliteResponseEnvelope { - response: TestRemoteSqliteResponse::Execute(response), + response, session: 1, })); } @@ -308,6 +326,9 @@ CREATE INDEX IF NOT EXISTS _rivet_schedule_history_schedule ON _rivet_schedule_history (schedule_id, fired_at DESC, id DESC); CREATE INDEX IF NOT EXISTS _rivet_schedule_history_fired_at ON _rivet_schedule_history (fired_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS _rivet_schedule_history_running + ON _rivet_schedule_history (result) + WHERE result = 0; CREATE TABLE IF NOT EXISTS _rivet_conns ( conn_id TEXT PRIMARY KEY, parameters BLOB NOT NULL, @@ -470,6 +491,49 @@ fn execute_test_sqlite( } } +fn execute_test_sqlite_batch( + conn: &rusqlite::Connection, + request: protocol::SqliteExecuteBatchRequest, +) -> protocol::SqliteExecuteBatchResponse { + let result = (|| { + conn.execute_batch("BEGIN")?; + let mut results = Vec::with_capacity(request.statements.len()); + for statement in request.statements { + let result = execute_test_sqlite_inner( + conn, + protocol::SqliteExecuteRequest { + namespace_id: request.namespace_id.clone(), + actor_id: request.actor_id.clone(), + generation: request.generation, + sql: statement.sql, + params: statement.params, + }, + ); + match result { + Ok(result) => results.push(result), + Err(error) => { + let _ = conn.execute_batch("ROLLBACK"); + return Err(error); + } + } + } + conn.execute_batch("COMMIT")?; + Ok(results) + })(); + match result { + Ok(results) => protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk( + protocol::SqliteExecuteBatchOk { results }, + ), + Err(error) => protocol::SqliteExecuteBatchResponse::SqliteErrorResponse( + protocol::SqliteErrorResponse { + group: "sqlite".to_owned(), + code: "internal_error".to_owned(), + message: error.to_string(), + }, + ), + } +} + fn execute_test_sqlite_inner( conn: &rusqlite::Connection, request: protocol::SqliteExecuteRequest, @@ -1212,7 +1276,7 @@ mod moved_tests { assert!(ctx.conns().any(|conn| conn.id() == "conn-c")); } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn init_alarms_arms_local_alarm_for_persisted_schedule_state() { let ctx = super::new_with_kv( "actor-init-alarms", @@ -1221,6 +1285,8 @@ mod moved_tests { "local", crate::kv::Kv::new_in_memory(), ); + let now_ms = 1_700_000_000_000; + ctx.set_schedule_time_for_tests(now_ms); let fired = Arc::new(AtomicUsize::new(0)); ctx.set_local_alarm_callback(Some(Arc::new({ let fired = fired.clone(); @@ -1231,18 +1297,15 @@ mod moved_tests { }) } }))); - ctx.at(now_timestamp_ms() + 20, "tick", &[1]) + ctx.at(now_ms + 20, "tick", &[1]) .await .expect("persist future schedule"); ctx.cancel_local_alarm_timeouts(); ctx.init_alarms().await; - for _ in 0..50 { - if fired.load(Ordering::SeqCst) > 0 { - break; - } - sleep(Duration::from_millis(10)).await; - } + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_millis(20)).await; + tokio::task::yield_now().await; assert_eq!(fired.load(Ordering::SeqCst), 1); } diff --git a/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs b/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs index 88c0fabfbb..a29cb38448 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs @@ -214,8 +214,21 @@ async fn run_remote_sqlite( else { continue; }; - let RemoteSqliteRequest::Execute(request) = request else { - panic!("migration test only expects remote execute requests"); + let request = match request { + RemoteSqliteRequest::Execute(request) => request, + RemoteSqliteRequest::ExecuteBatch(request) => { + let response = execute_remote_batch(&conn, &harness, request); + response_tx + .send(Ok(RemoteSqliteResponseEnvelope { + response: RemoteSqliteResponse::ExecuteBatch(response), + session: 1, + })) + .expect("remote sqlite response receiver should still be alive"); + continue; + } + RemoteSqliteRequest::Exec(_) => { + panic!("migration test only expects remote execute requests") + } }; harness.request_count.fetch_add(1, Ordering::Relaxed); let fail_commit_after_apply = request.sql == "COMMIT" @@ -321,6 +334,96 @@ async fn run_remote_sqlite( } } +fn execute_remote_batch( + conn: &Connection, + harness: &SqliteHarness, + request: protocol::SqliteExecuteBatchRequest, +) -> protocol::SqliteExecuteBatchResponse { + harness.request_count.fetch_add(1, Ordering::Relaxed); + harness.transaction_count.fetch_add(1, Ordering::Relaxed); + harness + .max_transaction_statements + .fetch_max(request.statements.len(), Ordering::SeqCst); + for statement in &request.statements { + if statement.sql.contains("queue_next_id") { + harness.queue_next_id_writes.fetch_add(1, Ordering::SeqCst); + } + } + + let fail_after_apply = harness + .fail_commit_after_apply + .swap(false, Ordering::SeqCst) + || harness + .indeterminate_commit_after_sql + .lock() + .as_ref() + .is_some_and(|sql| { + request + .statements + .iter() + .any(|statement| statement.sql.contains(sql)) + }); + if fail_after_apply { + harness.indeterminate_commit_after_sql.lock().take(); + } + + if let Err(error) = conn.execute_batch("BEGIN") { + return batch_sqlite_error("core", "internal_error", error.to_string()); + } + let mut results = Vec::with_capacity(request.statements.len()); + for statement in request.statements { + let should_fail = harness.fault.lock().as_ref().is_some_and(|fault| { + statement.sql.contains(fault.sql_contains) + && fault.blob_param.is_none_or(|expected| { + statement.params.as_ref().is_some_and(|params| { + params.iter().any(|param| { + matches!(param, protocol::SqliteBindParam::SqliteValueBlob(value) if value.value == expected) + }) + }) + }) + }); + if should_fail { + harness.fault.lock().take(); + let _ = conn.execute_batch("ROLLBACK"); + return batch_sqlite_error("test", "injected_failure", "injected migration fault"); + } + + match execute_remote_sql(conn, &statement.sql, statement.params) { + Ok(protocol::SqliteExecuteResponse::SqliteExecuteOk(ok)) => results.push(ok.result), + Ok(protocol::SqliteExecuteResponse::SqliteErrorResponse(error)) => { + let _ = conn.execute_batch("ROLLBACK"); + return protocol::SqliteExecuteBatchResponse::SqliteErrorResponse(error); + } + Err(error) => { + let _ = conn.execute_batch("ROLLBACK"); + return batch_sqlite_error("core", "internal_error", error.to_string()); + } + } + } + if let Err(error) = conn.execute_batch("COMMIT") { + let _ = conn.execute_batch("ROLLBACK"); + return batch_sqlite_error("core", "internal_error", error.to_string()); + } + if fail_after_apply { + return batch_sqlite_error("test", "indeterminate_result", "injected lost commit response"); + } + protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk(protocol::SqliteExecuteBatchOk { + results, + }) +} + +fn batch_sqlite_error( + group: &str, + code: &str, + message: impl Into, +) -> protocol::SqliteExecuteBatchResponse { + protocol::SqliteExecuteBatchResponse::SqliteErrorResponse(protocol::SqliteErrorResponse { + group: group.to_owned(), + code: code.to_owned(), + message: message.into(), + }) +} + fn execute_remote_sql( conn: &Connection, sql: &str, diff --git a/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs b/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs index 20034d3874..d7310ea320 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs @@ -14,7 +14,9 @@ mod moved_tests { use super::*; use crate::ActorConfig; - use crate::actor::schedule::{DEFAULT_MAX_HISTORY, MIN_INTERVAL_MS}; + use crate::actor::schedule::{ + DEFAULT_MAX_HISTORY, GLOBAL_HISTORY_PRUNE_INTERVAL, MIN_INTERVAL_MS, + }; use crate::testing::{ActorContextHarness, actor_context}; const BASE_TIME: i64 = 1_700_000_000_000; @@ -294,6 +296,19 @@ mod moved_tests { assert!(ctx.get_scheduled_event(&event_id).await.unwrap().is_none()); } + #[tokio::test] + async fn due_one_shots_are_claimed_across_batch_boundaries() { + let ctx = context("actor-one-shot-claim-batches"); + for index in 0..129 { + ctx.at(BASE_TIME, "tick", &[index]).await.unwrap(); + } + + let dispatches = ctx.take_due_schedule_dispatches().await.unwrap(); + assert_eq!(dispatches.len(), 129); + assert!(ctx.list_scheduled_events().await.unwrap().is_empty()); + assert!(ctx.take_due_schedule_dispatches().await.unwrap().is_empty()); + } + #[tokio::test] async fn different_recurring_names_can_run_concurrently() { let ctx = context("actor-concurrent-names"); @@ -598,7 +613,7 @@ mod moved_tests { .unwrap(); assert_eq!( count.rows, - vec![vec![crate::sqlite::ColumnValue::Integer(10_000)]] + vec![vec![crate::sqlite::ColumnValue::Integer(9_900)]] ); let range = ctx .sql() @@ -611,12 +626,22 @@ mod moved_tests { assert_eq!( range.rows, vec![vec![ - crate::sqlite::ColumnValue::Integer(3), + crate::sqlite::ColumnValue::Integer(103), crate::sqlite::ColumnValue::Integer(BASE_TIME + 5_000), ]] ); } + #[tokio::test] + async fn global_history_pruning_is_periodic() { + let ctx = context("actor-periodic-global-history-prune"); + assert!(ctx.should_prune_global_history()); + for _ in 1..GLOBAL_HISTORY_PRUNE_INTERVAL { + assert!(!ctx.should_prune_global_history()); + } + assert!(ctx.should_prune_global_history()); + } + #[tokio::test] async fn running_history_is_recovered_as_interrupted() { let ctx = context("actor-interrupted"); diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs b/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs new file mode 100644 index 0000000000..64f5f715cc --- /dev/null +++ b/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs @@ -0,0 +1,512 @@ +use std::fmt::Write as _; + +use rusqlite::types::Value; +use rusqlite::{Connection, StatementStatus, params_from_iter}; + +use crate::actor::internal_schema::{CREATE_META_TABLE, MIGRATIONS, READ_SCHEMA_VERSION_SQL}; +use crate::actor::{internal_storage, schedule}; +use crate::types::ListOpts; + +const REPRESENTATIVE_ROWS: usize = 10_000; + +#[derive(Clone, Copy, Debug)] +struct AllowedScan { + table: &'static str, + reason: &'static str, + bound: &'static str, +} + +#[derive(Clone, Debug, Default)] +struct QueryPlanExpectation { + forbid_full_scan_of: &'static [&'static str], + forbid_temp_sort: bool, + forbid_auto_index: bool, + expected_index: Option<&'static str>, + allowed_scans: &'static [AllowedScan], +} + +#[derive(Debug)] +struct QueryCase { + id: &'static str, + sql: String, + params: Vec, + expectation: QueryPlanExpectation, +} + +#[derive(Clone, Copy, Debug)] +struct StatementMetrics { + fullscan_steps: i32, + sorts: i32, + auto_indexes: i32, + vm_steps: i32, +} + +#[derive(Clone, Copy, Debug)] +struct CorrectnessOnlyQuery { + id: &'static str, + reason: &'static str, +} + +// These production statements are point inserts or primary-key conflict +// upserts. Their owning actor, queue, connection, KV, schedule, and migration +// suites provide correctness coverage; SQLite does not expose a useful query +// plan for them. +const CORRECTNESS_ONLY_QUERIES: &[CorrectnessOnlyQuery] = &[ + CorrectnessOnlyQuery { id: "actor.upsert", reason: "singleton primary-key upsert" }, + CorrectnessOnlyQuery { id: "actor_state.upsert", reason: "singleton primary-key upsert" }, + CorrectnessOnlyQuery { id: "connection.insert", reason: "single-row primary-key insert" }, + CorrectnessOnlyQuery { id: "connection_state.upsert", reason: "single-row primary-key upsert" }, + CorrectnessOnlyQuery { id: "queue.insert", reason: "single-row integer-primary-key insert" }, + CorrectnessOnlyQuery { id: "runtime.queue_next_id", reason: "singleton primary-key upsert" }, + CorrectnessOnlyQuery { id: "runtime.last_alarm_write", reason: "singleton primary-key upsert" }, + CorrectnessOnlyQuery { id: "runtime.inspector_token_write", reason: "singleton primary-key upsert" }, + CorrectnessOnlyQuery { id: "user_kv.upsert", reason: "single-row primary-key upsert" }, + CorrectnessOnlyQuery { id: "workflow_kv.upsert", reason: "single-row primary-key upsert" }, + CorrectnessOnlyQuery { id: "meta.upsert", reason: "single-row primary-key upsert" }, + CorrectnessOnlyQuery { id: "schedule.insert_one_shot", reason: "single-row primary-key insert" }, + CorrectnessOnlyQuery { id: "schedule.upsert_recurring", reason: "single-row primary-key upsert" }, + CorrectnessOnlyQuery { id: "schedule.history_insert", reason: "single-row integer-primary-key insert" }, +]; + +const EXCLUDED_QUERIES: &[CorrectnessOnlyQuery] = &[ + CorrectnessOnlyQuery { id: "inspector.schema_catalog", reason: "enumerates SQLite schema metadata rather than a growing core-owned internal table" }, + CorrectnessOnlyQuery { id: "inspector.table_count", reason: "operates on a user-selected user database table" }, + CorrectnessOnlyQuery { id: "inspector.table_rows", reason: "operates on a user-selected user database table and is capped at 500 rows" }, + CorrectnessOnlyQuery { id: "actor_runtime_socket.sql", reason: "caller-provided SQL is outside the core-owned query inventory" }, +]; + +fn indexed(expected_index: Option<&'static str>, tables: &'static [&'static str]) -> QueryPlanExpectation { + QueryPlanExpectation { + forbid_full_scan_of: tables, + forbid_temp_sort: true, + forbid_auto_index: true, + expected_index, + allowed_scans: &[], + } +} + +fn bounded_scan(allowed_scans: &'static [AllowedScan]) -> QueryPlanExpectation { + QueryPlanExpectation { + forbid_temp_sort: true, + forbid_auto_index: true, + allowed_scans, + ..QueryPlanExpectation::default() + } +} + +fn text(value: &str) -> Value { + Value::Text(value.to_owned()) +} + +fn fixture(row_count: usize) -> Connection { + let mut db = Connection::open_in_memory().expect("open efficiency fixture"); + db.execute_batch(CREATE_META_TABLE) + .expect("create real metadata schema"); + for migration in MIGRATIONS { + for sql in *migration { + db.execute_batch(sql).expect("apply real internal schema"); + } + } + + let tx = db.transaction().expect("begin fixture seed"); + tx.execute( + "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, 42, 'token', ?)", + [row_count as i64 + 1], + ) + .expect("seed runtime"); + tx.execute( + "INSERT INTO _rivet_actor (id, has_initialized, input) VALUES (1, 1, x'01')", + [], + ) + .expect("seed actor"); + tx.execute( + "INSERT INTO _rivet_actor_state (id, state) VALUES (1, x'02')", + [], + ) + .expect("seed actor state"); + tx.execute( + "INSERT INTO _rivet_meta (key, value) VALUES ('fixture', x'03')", + [], + ) + .expect("seed metadata"); + + 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"); + let mut conn_state_insert = tx + .prepare("INSERT INTO _rivet_conn_state (conn_id, state, server_message_index, client_message_index, subscriptions) VALUES (?, x'01', 0, 0, x'01')") + .expect("prepare connection state seed"); + let mut queue_insert = tx + .prepare("INSERT INTO _rivet_queue (id, name, body, created_at) VALUES (?, 'message', x'01', ?)") + .expect("prepare queue seed"); + let mut user_kv_insert = tx + .prepare("INSERT INTO _rivet_user_kv (key, value) VALUES (?, x'01')") + .expect("prepare user kv seed"); + let mut workflow_kv_insert = tx + .prepare("INSERT INTO _rivet_wf_kv (key, value) VALUES (?, x'01')") + .expect("prepare workflow kv seed"); + let mut schedule_insert = tx + .prepare("INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, 'run', x'01', ?, NULL, NULL, NULL, NULL, 100)") + .expect("prepare schedule seed"); + let mut history_insert = tx + .prepare("INSERT INTO _rivet_schedule_history (schedule_id, action, scheduled_at, fired_at, finished_at, result) VALUES (?, 'run', ?, ?, ?, ?)") + .expect("prepare history seed"); + for index in 0..row_count { + let key = format!("{index:08}"); + conn_insert.execute([&key]).expect("seed connection"); + conn_state_insert + .execute([&key]) + .expect("seed connection state"); + queue_insert + .execute((index as i64 + 1, index as i64)) + .expect("seed queue"); + user_kv_insert + .execute([key.as_bytes()]) + .expect("seed user kv"); + workflow_kv_insert + .execute([key.as_bytes()]) + .expect("seed workflow kv"); + let event_id = if index % 3 == 0 { + format!("at:{key}") + } else { + format!("cron:{key}") + }; + schedule_insert + .execute((&event_id, index as i64, (index % 3) as i64)) + .expect("seed schedule"); + let schedule_id = format!("cron:{:04}", index % 100); + history_insert + .execute(( + &schedule_id, + index as i64, + index as i64, + index as i64, + if index % 997 == 0 { 0_i64 } else { 1_i64 }, + )) + .expect("seed schedule history"); + } + drop(conn_insert); + drop(conn_state_insert); + drop(queue_insert); + drop(user_kv_insert); + drop(workflow_kv_insert); + drop(schedule_insert); + drop(history_insert); + tx.commit().expect("commit fixture seed"); + db.execute_batch("ANALYZE").expect("analyze fixture"); + db +} + +fn explain_plan(db: &Connection, sql: &str, params: &[Value]) -> Vec { + let mut statement = db + .prepare(&format!("EXPLAIN QUERY PLAN {sql}")) + .expect("prepare query plan"); + statement + .query_map(params_from_iter(params.iter()), |row| row.get::<_, String>(3)) + .expect("execute query plan") + .map(|detail| normalize_plan_detail(&detail.expect("read query plan detail"))) + .collect() +} + +fn normalize_plan_detail(detail: &str) -> String { + detail + .to_ascii_lowercase() + .replace(['`', '"', '[', ']'], "") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn plan_has_scan(plan: &[String], table: &str) -> bool { + let table = table.to_ascii_lowercase(); + plan.iter().any(|detail| { + (detail.starts_with("scan ") || detail.contains(" scan ")) + && detail.split_whitespace().any(|word| word == table) + }) +} + +fn assert_query_plan( + query_id: &str, + plan: &[String], + expectation: &QueryPlanExpectation, +) -> Result<(), String> { + let rendered = plan.join("\n"); + for table in expectation.forbid_full_scan_of { + if plan_has_scan(plan, table) + && !expectation + .allowed_scans + .iter() + .any(|allowed| allowed.table == *table) + { + return Err(format!( + "{query_id}: forbidden full scan of {table}\n{rendered}" + )); + } + } + if expectation.forbid_temp_sort && rendered.contains("temp b-tree") { + return Err(format!("{query_id}: temporary sort\n{rendered}")); + } + if expectation.forbid_auto_index && rendered.contains("automatic") { + return Err(format!("{query_id}: automatic index\n{rendered}")); + } + if let Some(expected_index) = expectation.expected_index + && !rendered.contains(&expected_index.to_ascii_lowercase()) + { + return Err(format!( + "{query_id}: expected index {expected_index}\n{rendered}" + )); + } + for allowed in expectation.allowed_scans { + assert!(!allowed.reason.is_empty(), "{query_id}: scan reason is empty"); + assert!(!allowed.bound.is_empty(), "{query_id}: scan bound is empty"); + } + Ok(()) +} + +fn execute_with_status(db: &Connection, sql: &str, params: &[Value]) -> StatementMetrics { + let mut statement = db.prepare(sql).expect("prepare measured statement"); + for status in [ + StatementStatus::FullscanStep, + StatementStatus::Sort, + StatementStatus::AutoIndex, + StatementStatus::VmStep, + ] { + statement.reset_status(status); + } + if statement.readonly() { + let mut rows = statement + .query(params_from_iter(params.iter())) + .expect("execute measured query"); + while rows.next().expect("read measured row").is_some() {} + } else { + statement + .execute(params_from_iter(params.iter())) + .expect("execute measured statement"); + } + StatementMetrics { + fullscan_steps: statement.get_status(StatementStatus::FullscanStep), + sorts: statement.get_status(StatementStatus::Sort), + auto_indexes: statement.get_status(StatementStatus::AutoIndex), + vm_steps: statement.get_status(StatementStatus::VmStep), + } +} + +fn query_catalog() -> Vec { + let all_schedules = &["_rivet_schedule_events"]; + let all_history = &["_rivet_schedule_history"]; + let all_user_kv = &["_rivet_user_kv"]; + vec![ + QueryCase { id: "actor.snapshot", sql: internal_storage::LOAD_ACTOR_SNAPSHOT_SQL.into(), params: vec![], expectation: indexed(None, &["_rivet_actor", "_rivet_actor_state"]) }, + QueryCase { id: "connection.list", sql: internal_storage::LOAD_CONNECTIONS_SQL.into(), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "c", reason: "actor startup restores every live connection", bound: "rows are lifecycle-bounded to currently live or hibernated connections and are deleted on disconnect" }]) }, + QueryCase { id: "migration.reset_schedules", sql: internal_storage::RESET_SCHEDULES_FOR_LEGACY_IMPORT_SQL.into(), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "one-time legacy import replaces the complete pre-import schedule set", bound: "the legacy actor snapshot containing the source schedule vector is capped at 256 KiB" }]) }, + QueryCase { id: "queue.next_id", sql: internal_storage::LOAD_QUEUE_NEXT_ID_SQL.into(), params: vec![], expectation: indexed(None, &["_rivet_runtime"]) }, + QueryCase { id: "queue.stats", sql: internal_storage::LOAD_QUEUE_STATS_SQL.into(), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_queue", reason: "startup validates persisted queue cardinality and maximum id", bound: "ActorConfig.max_queue_size caps queue rows (default 1,000)" }]) }, + QueryCase { id: "queue.list", sql: internal_storage::LOAD_QUEUE_MESSAGES_SQL.into(), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_queue", reason: "startup restores queued messages in primary-key order", bound: "ActorConfig.max_queue_size caps queue rows (default 1,000)" }]) }, + QueryCase { id: "queue.delete", sql: internal_storage::DELETE_QUEUE_MESSAGE_SQL.into(), params: vec![1_i64.into()], expectation: indexed(None, &["_rivet_queue"]) }, + QueryCase { id: "queue.reset", sql: internal_storage::RESET_QUEUE_SQL.into(), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_queue", reason: "explicit reset removes the complete logical queue", bound: "ActorConfig.max_queue_size caps queue rows (default 1,000)" }]) }, + QueryCase { id: "user_kv.batch_get", sql: internal_storage::user_kv_batch_get_sql(3), params: vec![Value::Blob(b"00000001".to_vec()), Value::Blob(b"00005000".to_vec()), Value::Blob(b"00009999".to_vec())], expectation: indexed(None, all_user_kv) }, + QueryCase { id: "user_kv.delete", sql: internal_storage::DELETE_USER_KV_SQL.into(), params: vec![Value::Blob(b"00000001".to_vec())], expectation: indexed(None, all_user_kv) }, + QueryCase { id: "user_kv.delete_range", sql: internal_storage::DELETE_USER_KV_RANGE_SQL.into(), params: vec![Value::Blob(b"00000010".to_vec()), Value::Blob(b"00000020".to_vec())], expectation: indexed(None, all_user_kv) }, + QueryCase { id: "user_kv.list_range", sql: internal_storage::user_kv_list_sql("WHERE key >= ? AND key < ?", false, true), params: vec![Value::Blob(b"00000010".to_vec()), Value::Blob(b"00000100".to_vec()), 20_i64.into()], expectation: indexed(None, all_user_kv) }, + QueryCase { id: "user_kv.list_prefix_open", sql: internal_storage::user_kv_list_sql("WHERE key >= ?", true, true), params: vec![Value::Blob(b"00009900".to_vec()), 20_i64.into()], expectation: indexed(None, all_user_kv) }, + QueryCase { id: "user_kv.list_prefix", sql: internal_storage::user_kv_list_sql("WHERE key >= ? AND key < ?", false, false), params: vec![Value::Blob(b"000000".to_vec()), Value::Blob(b"000001".to_vec())], expectation: indexed(None, all_user_kv) }, + QueryCase { id: "user_kv.list_all", sql: internal_storage::user_kv_list_sql("", false, false), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_user_kv", reason: "the explicit list-all API returns the complete actor KV namespace in primary-key order", bound: "callers can set ListOpts.limit when they do not want to materialize the complete namespace" }]) }, + QueryCase { id: "connection.delete_state", sql: internal_storage::DELETE_CONN_STATE_SQL.into(), params: vec![text("00000001")], expectation: indexed(None, &["_rivet_conn_state"]) }, + QueryCase { id: "connection.delete", sql: internal_storage::DELETE_CONN_SQL.into(), params: vec![text("00000001")], expectation: indexed(None, &["_rivet_conns"]) }, + QueryCase { id: "runtime.last_alarm", sql: internal_storage::LOAD_LAST_PUSHED_ALARM_SQL.into(), params: vec![], expectation: indexed(None, &["_rivet_runtime"]) }, + QueryCase { id: "runtime.inspector_token", sql: internal_storage::LOAD_INSPECTOR_TOKEN_SQL.into(), params: vec![], expectation: indexed(None, &["_rivet_runtime"]) }, + QueryCase { id: "meta.get", sql: internal_storage::LOAD_META_TEXT_SQL.into(), params: vec![text("fixture")], expectation: indexed(None, &["_rivet_meta"]) }, + QueryCase { id: "schema.version", sql: READ_SCHEMA_VERSION_SQL.into(), params: vec![text("schema_version")], expectation: indexed(None, &["_rivet_meta"]) }, + QueryCase { id: "schedule.cancel", sql: schedule::CANCEL_SCHEDULE_SQL.into(), params: vec![text("at:00000000"), 0_i64.into()], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.get_one_shot", sql: schedule::GET_SCHEDULED_EVENT_SQL.into(), params: vec![text("at:00000000"), 0_i64.into()], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.list_one_shots", sql: schedule::LIST_SCHEDULED_EVENTS_SQL.into(), params: vec![0_i64.into()], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "the API enumerates all one-shot schedules in trigger order", bound: "ActorConfig.max_schedules caps all schedules (default 1,000)" }]) }, + QueryCase { id: "schedule.delete_cron_history", sql: schedule::DELETE_CRON_HISTORY_SQL.into(), params: vec![text("cron:00000001"), text("cron:00000001"), 0_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, + QueryCase { id: "schedule.delete_cron", sql: schedule::DELETE_CRON_SQL.into(), params: vec![text("cron:00000001"), 0_i64.into()], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.delete_cron_if_action", sql: schedule::DELETE_CRON_IF_ACTION_SQL.into(), params: vec![text("cron:00000001"), 0_i64.into(), text("run")], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.list_recurring", sql: schedule::LIST_CRONS_SQL.into(), params: vec![0_i64.into()], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "the API enumerates all recurring schedules in trigger order", bound: "ActorConfig.max_schedules caps all schedules (default 1,000)" }]) }, + QueryCase { id: "schedule.history", sql: schedule::CRON_HISTORY_SQL.into(), params: vec![text("cron:0001"), 20_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, + QueryCase { id: "schedule.get", sql: schedule::LOAD_SCHEDULE_SQL.into(), params: vec![text("cron:00000001")], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.count", sql: schedule::COUNT_SCHEDULES_SQL.into(), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "SQLite represents its optimized COUNT(*) opcode as a covering-index scan in the query plan", bound: "runtime VM-step scaling verifies row-count-independent work; ActorConfig.max_schedules also caps rows" }]) }, + QueryCase { id: "schedule.due", sql: schedule::TAKE_DUE_SCHEDULES_SQL.into(), params: vec![5_i64.into()], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, + QueryCase { id: "schedule.claim_one_shots", sql: schedule::claim_one_shots_sql(3), params: vec![0_i64.into(), text("at:00000000"), 0_i64.into(), text("at:00000003"), 3_i64.into(), text("at:00000006"), 6_i64.into()], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.advance_skipped", sql: schedule::ADVANCE_SKIPPED_SCHEDULE_SQL.into(), params: vec![20_000_i64.into(), text("cron:00000001")], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.advance", sql: schedule::ADVANCE_SCHEDULE_SQL.into(), params: vec![20_000_i64.into(), 10_000_i64.into(), text("cron:00000002")], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.finish_history", sql: schedule::FINISH_HISTORY_SQL.into(), params: vec![20_000_i64.into(), 1_i64.into(), Value::Null, Value::Null, Value::Null, Value::Null, 1_i64.into(), 0_i64.into()], expectation: indexed(None, all_history) }, + QueryCase { id: "schedule.recover_history", sql: schedule::RECOVER_HISTORY_SQL.into(), params: vec![20_000_i64.into(), 2_i64.into(), text("schedule"), text("interrupted"), text("interrupted"), Value::Null], expectation: indexed(Some("_rivet_schedule_history_running"), all_history) }, + QueryCase { id: "schedule.next_future", sql: schedule::NEXT_FUTURE_SCHEDULE_SQL.into(), params: vec![5_000_i64.into()], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, + QueryCase { id: "schedule.next", sql: schedule::NEXT_SCHEDULE_SQL.into(), params: vec![], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, + QueryCase { id: "schedule.prune", sql: schedule::PRUNE_SCHEDULE_HISTORY_SQL.into(), params: vec![text("cron:0001"), text("cron:0001"), 20_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, + QueryCase { id: "schedule.prune_global", sql: schedule::PRUNE_GLOBAL_HISTORY_SQL.into(), params: vec![schedule::GLOBAL_HISTORY_RETAINED_ROWS.into()], expectation: QueryPlanExpectation { forbid_full_scan_of: all_history, forbid_temp_sort: true, forbid_auto_index: true, expected_index: Some("_rivet_schedule_history_fired_at"), allowed_scans: &[AllowedScan { table: "_rivet_schedule_history", reason: "global pruning walks the history ordering index to skip retained rows and delete only the overflow", bound: "periodic pruning retains 9,900 rows, leaving headroom beneath MAX_ACTOR_HISTORY" }] } }, + QueryCase { id: "cleanup.select_chunk", sql: internal_storage::clear_table_select_sql("_rivet_user_kv", "key", "length(key) + length(value)"), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_user_kv", reason: "interrupted legacy import cleanup walks one primary-key chunk", bound: "each statement is limited to KV_TX_MAX_ROWS (128)" }]) }, + QueryCase { id: "cleanup.delete_row", sql: internal_storage::clear_table_delete_sql("_rivet_user_kv", "key"), params: vec![Value::Blob(b"00000001".to_vec())], expectation: indexed(None, all_user_kv) }, + ] +} + +#[test] +fn sql_efficiency_catalog_has_stable_unique_ids_and_documented_scans() { + let cases = query_catalog(); + let mut ids = cases + .iter() + .map(|case| case.id) + .chain(CORRECTNESS_ONLY_QUERIES.iter().map(|query| query.id)) + .chain(EXCLUDED_QUERIES.iter().map(|query| query.id)) + .collect::>(); + ids.sort_unstable(); + let original_len = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), original_len, "query catalog IDs must be unique"); + assert!(original_len >= 50, "query inventory unexpectedly shrank"); + for case in cases { + for scan in case.expectation.allowed_scans { + assert!(!scan.reason.trim().is_empty(), "{} scan needs a reason", case.id); + assert!(!scan.bound.trim().is_empty(), "{} scan needs a bound", case.id); + } + } + for query in CORRECTNESS_ONLY_QUERIES.iter().chain(EXCLUDED_QUERIES) { + assert!( + !query.reason.trim().is_empty(), + "{} needs a classification reason", + query.id + ); + } +} + +#[test] +fn sql_efficiency_production_queries_use_expected_plans() { + let db = fixture(REPRESENTATIVE_ROWS); + let mut failures = String::new(); + for case in query_catalog() { + let plan = explain_plan(&db, &case.sql, &case.params); + if let Err(error) = assert_query_plan(case.id, &plan, &case.expectation) { + let _ = writeln!(failures, "{error}\n"); + } + } + assert!(failures.is_empty(), "query plan failures:\n{failures}"); +} + +#[test] +fn sql_efficiency_indexed_queries_have_no_runtime_scan_sort_or_auto_index() { + let db = fixture(REPRESENTATIVE_ROWS); + let mut failures = String::new(); + for case in query_catalog() + .into_iter() + .filter(|case| case.expectation.allowed_scans.is_empty()) + { + let metrics = execute_with_status(&db, &case.sql, &case.params); + if metrics.fullscan_steps != 0 || metrics.sorts != 0 || metrics.auto_indexes != 0 { + let plan = explain_plan(&db, &case.sql, &case.params); + let _ = writeln!( + failures, + "{}: metrics={metrics:?}\n{}\n", + case.id, + plan.join("\n") + ); + } + } + assert!(failures.is_empty(), "runtime efficiency failures:\n{failures}"); +} + +fn measured_vm_steps(row_count: usize, sql: &str, params: &[Value]) -> i32 { + let db = fixture(row_count); + execute_with_status(&db, sql, params).vm_steps +} + +#[test] +fn sql_efficiency_hot_paths_do_not_scale_with_unrelated_rows() { + let cases = [ + ( + "connection.delete", + internal_storage::DELETE_CONN_SQL.to_owned(), + vec![Value::Text("00000050".to_owned())], + ), + ( + "queue.delete", + internal_storage::DELETE_QUEUE_MESSAGE_SQL.to_owned(), + vec![Value::Integer(50)], + ), + ( + "user_kv.batch_get", + internal_storage::user_kv_batch_get_sql(1), + vec![Value::Blob(b"00000050".to_vec())], + ), + ( + "schedule.get", + schedule::LOAD_SCHEDULE_SQL.to_owned(), + vec![Value::Text("cron:00000050".to_owned())], + ), + ( + "schedule.due", + schedule::TAKE_DUE_SCHEDULES_SQL.to_owned(), + vec![Value::Integer(5)], + ), + ]; + for (id, sql, params) in cases { + let small = measured_vm_steps(100, &sql, ¶ms); + let large = measured_vm_steps(REPRESENTATIVE_ROWS, &sql, ¶ms); + assert!( + large <= small.saturating_mul(3).saturating_add(100), + "{id} scales with unrelated cardinality: small={small}, large={large}" + ); + } +} + +#[test] +fn sql_efficiency_negative_control_detects_unindexed_access() { + let db = fixture(REPRESENTATIVE_ROWS); + let sql = "SELECT event_id FROM _rivet_schedule_events WHERE action = ? ORDER BY event_id"; + let params = vec![Value::Text("run".to_owned())]; + let plan = explain_plan(&db, sql, ¶ms); + let expectation = indexed(None, &["_rivet_schedule_events"]); + let error = assert_query_plan("negative.unindexed_schedule_action", &plan, &expectation) + .expect_err("negative control must detect a full scan"); + let metrics = execute_with_status(&db, sql, ¶ms); + assert!(error.contains("forbidden full scan"), "{error}"); + assert!( + metrics.fullscan_steps > 0 || metrics.sorts > 0, + "negative control must record inefficient work: {metrics:?}" + ); +} + +#[tokio::test] +async fn sql_efficiency_unlimited_kv_listing_uses_one_ordered_query() { + let ctx = crate::testing::actor_context("sql-efficiency-list", "actor", Vec::new(), "local"); + crate::actor::internal_schema::ensure_internal_schema(ctx.sql()) + .await + .expect("initialize KV listing fixture"); + let entries = (0..300) + .map(|index| { + ( + format!("prefix:{index:04}").into_bytes(), + format!("value:{index:04}").into_bytes(), + ) + }) + .collect::>(); + let refs = entries + .iter() + .map(|(key, value)| (key.as_slice(), value.as_slice())) + .collect::>(); + internal_storage::user_kv_batch_put(ctx.sql(), &refs) + .await + .expect("seed KV listing fixture"); + + let forward = internal_storage::user_kv_list_prefix( + ctx.sql(), + b"prefix:", + ListOpts::default(), + ) + .await + .expect("list all KV values forward"); + assert_eq!(forward, entries); + + let reverse = internal_storage::user_kv_list_prefix( + ctx.sql(), + b"prefix:", + ListOpts { + reverse: true, + limit: None, + }, + ) + .await + .expect("list all KV values in reverse"); + assert_eq!(reverse, entries.into_iter().rev().collect::>()); +} diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs b/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs index e265b82020..1c09189a19 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/sqlite.rs @@ -262,6 +262,51 @@ fn send_execute_ok(response_tx: oneshot::Sender, +) -> ( + protocol::SqliteExecuteBatchRequest, + oneshot::Sender>, +) { + let message = tokio::time::timeout(std::time::Duration::from_secs(2), envoy_rx.recv()) + .await + .expect("timed out waiting for remote sqlite batch request") + .expect("envoy request channel closed"); + let ToEnvoyMessage::RemoteSqliteRequest { + request: RemoteSqliteRequest::ExecuteBatch(request), + expected_session: _, + response_tx, + } = message + else { + panic!("expected remote sqlite execute batch request"); + }; + (request, response_tx) +} + +fn send_execute_batch_ok( + response_tx: oneshot::Sender>, + result_count: usize, +) { + let result = protocol::SqliteExecuteResult { + columns: Vec::new(), + rows: Vec::new(), + changes: 0, + last_insert_row_id: None, + }; + response_tx + .send(Ok(RemoteSqliteResponseEnvelope { + response: RemoteSqliteResponse::ExecuteBatch( + protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk( + protocol::SqliteExecuteBatchOk { + results: vec![result; result_count], + }, + ), + ), + session: 1, + })) + .expect("remote sqlite requester dropped response"); +} + #[test] fn remote_backend_selection_is_independent_of_user_database_flag() { assert_eq!( @@ -481,10 +526,11 @@ async fn remote_execute_batch_uses_one_coordinated_transaction() { } }); - respond_to_execute(&mut envoy_rx, "BEGIN").await; - respond_to_execute(&mut envoy_rx, "insert-one").await; - respond_to_execute(&mut envoy_rx, "insert-two").await; - respond_to_execute(&mut envoy_rx, "COMMIT").await; + let (request, response_tx) = receive_execute_batch(&mut envoy_rx).await; + assert_eq!(request.statements.len(), 2); + assert_eq!(request.statements[0].sql, "insert-one"); + assert_eq!(request.statements[1].sql, "insert-two"); + send_execute_batch_ok(response_tx, 2); let results = batch.await.unwrap().unwrap(); assert_eq!(results.len(), 2); @@ -511,12 +557,23 @@ async fn remote_execute_batch_rolls_back_after_statement_failure() { } }); - respond_to_execute(&mut envoy_rx, "BEGIN").await; - let failure = receive_execute(&mut envoy_rx, "fails").await; + let (request, failure) = receive_execute_batch(&mut envoy_rx).await; + assert_eq!(request.statements.len(), 1); + assert_eq!(request.statements[0].sql, "fails"); failure - .send(Err(anyhow::anyhow!("injected statement failure"))) + .send(Ok(RemoteSqliteResponseEnvelope { + response: RemoteSqliteResponse::ExecuteBatch( + protocol::SqliteExecuteBatchResponse::SqliteErrorResponse( + protocol::SqliteErrorResponse { + group: "sqlite".to_owned(), + code: "internal_error".to_owned(), + message: "injected statement failure".to_owned(), + }, + ), + ), + session: 1, + })) .expect("remote sqlite requester dropped response"); - respond_to_execute(&mut envoy_rx, "ROLLBACK").await; let error = batch.await.unwrap().expect_err("batch should fail"); assert!(format!("{error:#}").contains("injected statement failure")); diff --git a/rivetkit-rust/packages/rivetkit-core/tests/task.rs b/rivetkit-rust/packages/rivetkit-core/tests/task.rs index 52710311f9..d7e620c3d8 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/task.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/task.rs @@ -455,15 +455,20 @@ pub(crate) mod moved_tests { else { continue; }; - let RemoteSqliteRequest::Execute(request) = request else { - continue; - }; let response = { let conn = conn.lock().expect("test sqlite connection poisoned"); - execute_test_sqlite(&conn, request) + match request { + RemoteSqliteRequest::Execute(request) => { + RemoteSqliteResponse::Execute(execute_test_sqlite(&conn, request)) + } + RemoteSqliteRequest::ExecuteBatch(request) => { + RemoteSqliteResponse::ExecuteBatch(execute_test_sqlite_batch(&conn, request)) + } + RemoteSqliteRequest::Exec(_) => continue, + } }; let _ = response_tx.send(Ok(RemoteSqliteResponseEnvelope { - response: RemoteSqliteResponse::Execute(response), + response, session: 1, })); } @@ -490,6 +495,49 @@ pub(crate) mod moved_tests { } } + fn execute_test_sqlite_batch( + conn: &rusqlite::Connection, + request: protocol::SqliteExecuteBatchRequest, + ) -> protocol::SqliteExecuteBatchResponse { + let result = (|| { + conn.execute_batch("BEGIN")?; + let mut results = Vec::with_capacity(request.statements.len()); + for statement in request.statements { + let result = execute_test_sqlite_inner( + conn, + protocol::SqliteExecuteRequest { + namespace_id: request.namespace_id.clone(), + actor_id: request.actor_id.clone(), + generation: request.generation, + sql: statement.sql, + params: statement.params, + }, + ); + match result { + Ok(result) => results.push(result), + Err(error) => { + let _ = conn.execute_batch("ROLLBACK"); + return Err(error); + } + } + } + conn.execute_batch("COMMIT")?; + Ok(results) + })(); + match result { + Ok(results) => protocol::SqliteExecuteBatchResponse::SqliteExecuteBatchOk( + protocol::SqliteExecuteBatchOk { results }, + ), + Err(error) => protocol::SqliteExecuteBatchResponse::SqliteErrorResponse( + protocol::SqliteErrorResponse { + group: "sqlite".to_owned(), + code: "internal_error".to_owned(), + message: error.to_string(), + }, + ), + } + } + fn execute_test_sqlite_inner( conn: &rusqlite::Connection, request: protocol::SqliteExecuteRequest, From 6e6798e9e32bdd538116ef6e1be78168864fe884 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 22:31:24 -0700 Subject: [PATCH 7/8] test(rivetkit): simplify sqlite efficiency coverage --- .../src/actor/internal_storage.rs | 3 + .../rivetkit-core/tests/sql_efficiency.rs | 65 +------------------ 2 files changed, 5 insertions(+), 63 deletions(-) diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs index 9ce1441974..69e0cade82 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs @@ -1016,6 +1016,9 @@ async fn clear_table_bounded( key_column: &str, size_expression: &str, ) -> Result<()> { + // An interrupted legacy import can leave enough data that one `DELETE FROM` + // exceeds depot's dirty-page commit limit. Delete estimated-size chunks in + // separate transactions so cleanup can always make progress. let mut cleared_rows = 0usize; let mut cleared_bytes = 0usize; let mut last_logged_rows = 0usize; diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs b/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs index 64f5f715cc..47b879e9e9 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs @@ -7,6 +7,8 @@ use crate::actor::internal_schema::{CREATE_META_TABLE, MIGRATIONS, READ_SCHEMA_V use crate::actor::{internal_storage, schedule}; use crate::types::ListOpts; +// This suite covers SQL owned by rivetkit-core. User-provided SQL, including +// Actor Runtime Socket requests, is intentionally outside its scope. const REPRESENTATIVE_ROWS: usize = 10_000; #[derive(Clone, Copy, Debug)] @@ -41,40 +43,6 @@ struct StatementMetrics { vm_steps: i32, } -#[derive(Clone, Copy, Debug)] -struct CorrectnessOnlyQuery { - id: &'static str, - reason: &'static str, -} - -// These production statements are point inserts or primary-key conflict -// upserts. Their owning actor, queue, connection, KV, schedule, and migration -// suites provide correctness coverage; SQLite does not expose a useful query -// plan for them. -const CORRECTNESS_ONLY_QUERIES: &[CorrectnessOnlyQuery] = &[ - CorrectnessOnlyQuery { id: "actor.upsert", reason: "singleton primary-key upsert" }, - CorrectnessOnlyQuery { id: "actor_state.upsert", reason: "singleton primary-key upsert" }, - CorrectnessOnlyQuery { id: "connection.insert", reason: "single-row primary-key insert" }, - CorrectnessOnlyQuery { id: "connection_state.upsert", reason: "single-row primary-key upsert" }, - CorrectnessOnlyQuery { id: "queue.insert", reason: "single-row integer-primary-key insert" }, - CorrectnessOnlyQuery { id: "runtime.queue_next_id", reason: "singleton primary-key upsert" }, - CorrectnessOnlyQuery { id: "runtime.last_alarm_write", reason: "singleton primary-key upsert" }, - CorrectnessOnlyQuery { id: "runtime.inspector_token_write", reason: "singleton primary-key upsert" }, - CorrectnessOnlyQuery { id: "user_kv.upsert", reason: "single-row primary-key upsert" }, - CorrectnessOnlyQuery { id: "workflow_kv.upsert", reason: "single-row primary-key upsert" }, - CorrectnessOnlyQuery { id: "meta.upsert", reason: "single-row primary-key upsert" }, - CorrectnessOnlyQuery { id: "schedule.insert_one_shot", reason: "single-row primary-key insert" }, - CorrectnessOnlyQuery { id: "schedule.upsert_recurring", reason: "single-row primary-key upsert" }, - CorrectnessOnlyQuery { id: "schedule.history_insert", reason: "single-row integer-primary-key insert" }, -]; - -const EXCLUDED_QUERIES: &[CorrectnessOnlyQuery] = &[ - CorrectnessOnlyQuery { id: "inspector.schema_catalog", reason: "enumerates SQLite schema metadata rather than a growing core-owned internal table" }, - CorrectnessOnlyQuery { id: "inspector.table_count", reason: "operates on a user-selected user database table" }, - CorrectnessOnlyQuery { id: "inspector.table_rows", reason: "operates on a user-selected user database table and is capped at 500 rows" }, - CorrectnessOnlyQuery { id: "actor_runtime_socket.sql", reason: "caller-provided SQL is outside the core-owned query inventory" }, -]; - fn indexed(expected_index: Option<&'static str>, tables: &'static [&'static str]) -> QueryPlanExpectation { QueryPlanExpectation { forbid_full_scan_of: tables, @@ -342,35 +310,6 @@ fn query_catalog() -> Vec { ] } -#[test] -fn sql_efficiency_catalog_has_stable_unique_ids_and_documented_scans() { - let cases = query_catalog(); - let mut ids = cases - .iter() - .map(|case| case.id) - .chain(CORRECTNESS_ONLY_QUERIES.iter().map(|query| query.id)) - .chain(EXCLUDED_QUERIES.iter().map(|query| query.id)) - .collect::>(); - ids.sort_unstable(); - let original_len = ids.len(); - ids.dedup(); - assert_eq!(ids.len(), original_len, "query catalog IDs must be unique"); - assert!(original_len >= 50, "query inventory unexpectedly shrank"); - for case in cases { - for scan in case.expectation.allowed_scans { - assert!(!scan.reason.trim().is_empty(), "{} scan needs a reason", case.id); - assert!(!scan.bound.trim().is_empty(), "{} scan needs a bound", case.id); - } - } - for query in CORRECTNESS_ONLY_QUERIES.iter().chain(EXCLUDED_QUERIES) { - assert!( - !query.reason.trim().is_empty(), - "{} needs a classification reason", - query.id - ); - } -} - #[test] fn sql_efficiency_production_queries_use_expected_plans() { let db = fixture(REPRESENTATIVE_ROWS); From c1e6f0fa8eeef03dbbe135089be51f581b902520 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 23:01:12 -0700 Subject: [PATCH 8/8] refactor(rivetkit): centralize internal sqlite storage --- .../pegboard-envoy/src/ws_to_tunnel_task.rs | 25 +-- .../tests/support/ws_to_tunnel_task.rs | 41 +++- .../mod.rs} | 107 +++------ .../src/actor/internal_storage/queries.rs | 112 ++++++++++ .../schema.rs} | 30 ++- .../packages/rivetkit-core/src/actor/mod.rs | 1 - .../rivetkit-core/src/actor/schedule.rs | 53 ++--- .../packages/rivetkit-core/src/actor/task.rs | 2 +- .../packages/rivetkit-core/src/testing.rs | 88 +------- .../packages/rivetkit-core/tests/context.rs | 205 +----------------- .../tests/migrate_kv_to_sqlite.rs | 2 +- .../packages/rivetkit-core/tests/schedule.rs | 45 ++++ .../rivetkit-core/tests/sql_efficiency.rs | 52 ++--- .../packages/rivetkit-core/tests/task.rs | 2 +- 14 files changed, 306 insertions(+), 459 deletions(-) rename rivetkit-rust/packages/rivetkit-core/src/actor/{internal_storage.rs => internal_storage/mod.rs} (84%) create mode 100644 rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/queries.rs rename rivetkit-rust/packages/rivetkit-core/src/actor/{internal_schema.rs => internal_storage/schema.rs} (91%) 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 0e55d354c7..29b0d11d11 100644 --- a/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs +++ b/engine/packages/pegboard-envoy/src/ws_to_tunnel_task.rs @@ -44,7 +44,6 @@ use crate::{ }; const MAX_REMOTE_SQL_BIND_BYTES: usize = 128 * 1024; -const MAX_REMOTE_SQL_BATCH_STATEMENTS: usize = 256; /// Wall-clock threshold above which a single handle_message invocation is logged as a head-of-line /// blocking risk. The ws_to_tunnel_task loop is strictly serial per envoy, so any handler that @@ -1731,22 +1730,7 @@ async fn handle_remote_sqlite_execute_batch( request.generation, ) .await?; - if request.statements.len() > MAX_REMOTE_SQL_BATCH_STATEMENTS { - bail!( - "remote sqlite batch had {} statements, exceeding limit {MAX_REMOTE_SQL_BATCH_STATEMENTS}", - request.statements.len() - ); - } - let bind_bytes = request - .statements - .iter() - .map(|statement| bind_params_bytes(statement.params.as_ref())) - .sum::(); - if bind_bytes > MAX_REMOTE_SQL_BIND_BYTES { - bail!( - "remote sqlite batch bind params had {bind_bytes} bytes, exceeding limit {MAX_REMOTE_SQL_BIND_BYTES}" - ); - } + validate_remote_sqlite_batch(&request.statements)?; let actor_db = actor_db(ctx, conn, request.actor_id.clone()).await?; let database = remote_sqlite_executor_from_parts( @@ -1975,6 +1959,13 @@ fn validate_remote_sqlite_params(params: Option<&Vec> Ok(()) } +fn validate_remote_sqlite_batch(statements: &[protocol::SqliteBatchStatement]) -> Result<()> { + for statement in statements { + validate_remote_sqlite_params(statement.params.as_ref())?; + } + Ok(()) +} + fn bind_params_bytes(params: Option<&Vec>) -> usize { params.map_or(0, |params| { params.iter().map(bind_param_bytes).sum::() diff --git a/engine/packages/pegboard-envoy/tests/support/ws_to_tunnel_task.rs b/engine/packages/pegboard-envoy/tests/support/ws_to_tunnel_task.rs index a23391f74a..ab5a51ccfe 100644 --- a/engine/packages/pegboard-envoy/tests/support/ws_to_tunnel_task.rs +++ b/engine/packages/pegboard-envoy/tests/support/ws_to_tunnel_task.rs @@ -105,7 +105,8 @@ use super::{ }, cached_active_sqlite_actor, cached_serverless_sqlite_generation, remote_sqlite_executor_cell, remote_sqlite_executor_from_parts, spawn_tracked_remote_sqlite_task, - validate_remote_sqlite_params, validate_sqlite_get_page_range_request, + validate_remote_sqlite_batch, validate_remote_sqlite_params, + validate_sqlite_get_page_range_request, }; use crate::conn::{ RemoteSqliteExecutors, RemoteSqliteInflight, remote_sqlite_inflight_count, @@ -479,6 +480,44 @@ fn validate_remote_sqlite_params_bounds_total_bind_bytes() { assert!(err.to_string().contains("bind params had")); } +#[test] +fn validate_remote_sqlite_batch_preserves_per_statement_limits() { + let small_statement = || rivet_envoy_protocol::SqliteBatchStatement { + sql: "SELECT ?".to_string(), + params: Some(vec![ + rivet_envoy_protocol::SqliteBindParam::SqliteValueBlob( + rivet_envoy_protocol::SqliteValueBlob { + value: vec![0; 80 * 1024], + }, + ), + ]), + }; + validate_remote_sqlite_batch(&[small_statement(), small_statement()]) + .expect("aggregate batch bindings may exceed the per-statement limit"); + + let many_statements = (0..257) + .map(|_| rivet_envoy_protocol::SqliteBatchStatement { + sql: "SELECT 1".to_string(), + params: None, + }) + .collect::>(); + validate_remote_sqlite_batch(&many_statements) + .expect("batch statement count should not have an envoy-only limit"); + + let oversized_statement = rivet_envoy_protocol::SqliteBatchStatement { + sql: "SELECT ?".to_string(), + params: Some(vec![ + rivet_envoy_protocol::SqliteBindParam::SqliteValueBlob( + rivet_envoy_protocol::SqliteValueBlob { + value: vec![0; super::MAX_REMOTE_SQL_BIND_BYTES + 1], + }, + ), + ]), + }; + validate_remote_sqlite_batch(&[oversized_statement]) + .expect_err("an individually oversized statement should still fail"); +} + async fn has_remote_sqlite_executor( executors: &RemoteSqliteExecutors, actor_id: &str, diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/mod.rs similarity index 84% rename from rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs rename to rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/mod.rs index 69e0cade82..3687d10a7b 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/mod.rs @@ -14,6 +14,11 @@ use crate::error::KvRuntimeError; use crate::sqlite::{BindParam, ColumnValue, SqliteBatchStatement, SqliteDb}; use crate::types::ListOpts; +pub(crate) mod queries; +pub(crate) mod schema; + +pub(crate) use queries::*; + const USER_KV_VALUE_LIMIT: usize = 128 * 1024; /// Mirrors the engine-side `MAX_KEY_SIZE` in /// `engine/packages/pegboard/src/actor_kv/` so the documented 2 KiB key limit @@ -31,59 +36,6 @@ pub(crate) const KV_TX_MAX_PAYLOAD_BYTES: usize = 512 * 1024; pub(crate) const KV_TX_MAX_ROWS: usize = 128; const CONNECTION_DESTINATION_ROWS_PER_RECORD: usize = 2; -pub(crate) const LOAD_ACTOR_SNAPSHOT_SQL: &str = "SELECT a.has_initialized, a.input, s.state FROM _rivet_actor a JOIN _rivet_actor_state s ON s.id = a.id WHERE a.id = 1"; -pub(crate) const LOAD_CONNECTIONS_SQL: &str = "SELECT c.conn_id, c.parameters, s.state, s.subscriptions, c.gateway_id, c.request_id, s.server_message_index, s.client_message_index, c.request_path, c.request_headers FROM _rivet_conns c JOIN _rivet_conn_state s ON s.conn_id = c.conn_id ORDER BY c.conn_id"; -pub(crate) const RESET_SCHEDULES_FOR_LEGACY_IMPORT_SQL: &str = - "DELETE FROM _rivet_schedule_events"; -pub(crate) const LOAD_QUEUE_NEXT_ID_SQL: &str = - "SELECT queue_next_id FROM _rivet_runtime WHERE id = 1"; -pub(crate) const LOAD_QUEUE_STATS_SQL: &str = "SELECT COUNT(*), MAX(id) FROM _rivet_queue"; -pub(crate) const LOAD_QUEUE_MESSAGES_SQL: &str = - "SELECT id, name, body, created_at FROM _rivet_queue ORDER BY id"; -pub(crate) const DELETE_QUEUE_MESSAGE_SQL: &str = "DELETE FROM _rivet_queue WHERE id = ?"; -pub(crate) const RESET_QUEUE_SQL: &str = "DELETE FROM _rivet_queue"; -pub(crate) const DELETE_USER_KV_SQL: &str = "DELETE FROM _rivet_user_kv WHERE key = ?"; -pub(crate) const DELETE_USER_KV_RANGE_SQL: &str = - "DELETE FROM _rivet_user_kv WHERE key >= ? AND key < ?"; -pub(crate) const DELETE_CONN_STATE_SQL: &str = - "DELETE FROM _rivet_conn_state WHERE conn_id = ?"; -pub(crate) const DELETE_CONN_SQL: &str = "DELETE FROM _rivet_conns WHERE conn_id = ?"; -pub(crate) const LOAD_LAST_PUSHED_ALARM_SQL: &str = - "SELECT last_pushed_alarm FROM _rivet_runtime WHERE id = 1"; -pub(crate) const LOAD_INSPECTOR_TOKEN_SQL: &str = - "SELECT inspector_token FROM _rivet_runtime WHERE id = 1"; -pub(crate) const LOAD_META_TEXT_SQL: &str = "SELECT value FROM _rivet_meta WHERE key = ?"; - -pub(crate) fn user_kv_batch_get_sql(key_count: usize) -> String { - let placeholders = std::iter::repeat_n("?", key_count) - .collect::>() - .join(", "); - format!("SELECT key, value FROM _rivet_user_kv WHERE key IN ({placeholders})") -} - -pub(crate) fn user_kv_list_sql(where_clause: &str, reverse: bool, limited: bool) -> String { - let order = if reverse { "DESC" } else { "ASC" }; - let limit_clause = if limited { " LIMIT ?" } else { "" }; - format!( - "SELECT key, value FROM _rivet_user_kv {where_clause} ORDER BY key {order}{limit_clause}" - ) -} - -pub(crate) fn clear_table_select_sql( - table: &str, - key_column: &str, - size_expression: &str, -) -> String { - format!( - "SELECT {key_column}, {size_expression} FROM {table} ORDER BY {key_column} LIMIT {}", - KV_TX_MAX_ROWS - ) -} - -pub(crate) fn clear_table_delete_sql(table: &str, key_column: &str) -> String { - format!("DELETE FROM {table} WHERE {key_column} = ?") -} - /// Splits `entries` into contiguous chunks that each stay within the /// per-transaction row and payload budgets. A single oversized entry gets its /// own chunk; per-entry limits are enforced by the callers. @@ -148,14 +100,14 @@ pub(crate) async fn load_actor_snapshot(db: &SqliteDb) -> Result Result<()> { let statements = vec![ SqliteBatchStatement { - sql: "INSERT INTO _rivet_actor (id, has_initialized, input) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET has_initialized = excluded.has_initialized, input = excluded.input".to_owned(), + sql: UPSERT_ACTOR_SQL.to_owned(), params: Some(vec![ BindParam::Integer(if actor.has_initialized { 1 } else { 0 }), optional_blob_param(actor.input.clone()), ]), }, SqliteBatchStatement { - sql: "INSERT INTO _rivet_actor_state (id, state) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state".to_owned(), + sql: UPSERT_ACTOR_STATE_SQL.to_owned(), params: Some(vec![BindParam::Blob(actor.state.clone())]), }, ]; @@ -176,14 +128,14 @@ pub(crate) async fn import_legacy_actor_snapshot( ) -> Result<()> { let mut statements = vec![ SqliteBatchStatement { - sql: "INSERT INTO _rivet_actor (id, has_initialized, input) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET has_initialized = excluded.has_initialized, input = excluded.input".to_owned(), + sql: UPSERT_ACTOR_SQL.to_owned(), params: Some(vec![ BindParam::Integer(if actor.has_initialized { 1 } else { 0 }), optional_blob_param(actor.input.clone()), ]), }, SqliteBatchStatement { - sql: "INSERT INTO _rivet_actor_state (id, state) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state".to_owned(), + sql: UPSERT_ACTOR_STATE_SQL.to_owned(), params: Some(vec![BindParam::Blob(actor.state.clone())]), }, SqliteBatchStatement { @@ -193,7 +145,7 @@ pub(crate) async fn import_legacy_actor_snapshot( ]; for event in &actor.scheduled_events { statements.push(SqliteBatchStatement { - sql: "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".to_owned(), + sql: INSERT_SCHEDULE_EVENT_SQL.to_owned(), params: Some(vec![ BindParam::Text(event.event_id.clone()), BindParam::Integer(event.timestamp), @@ -264,14 +216,14 @@ fn build_actor_core_and_connection_statements( if let Some(actor) = actor { statements.push(SqliteBatchStatement { - sql: "INSERT INTO _rivet_actor (id, has_initialized, input) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET has_initialized = excluded.has_initialized, input = excluded.input".to_owned(), + sql: UPSERT_ACTOR_SQL.to_owned(), params: Some(vec![ BindParam::Integer(if actor.has_initialized { 1 } else { 0 }), optional_blob_param(actor.input.clone()), ]), }); statements.push(SqliteBatchStatement { - sql: "INSERT INTO _rivet_actor_state (id, state) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state".to_owned(), + sql: UPSERT_ACTOR_STATE_SQL.to_owned(), params: Some(vec![BindParam::Blob(actor.state.clone())]), }); } @@ -424,8 +376,7 @@ pub(crate) async fn persist_queue_message( let next_id = i64::try_from(next_id).context("queue next id exceeds sqlite integer range")?; db.execute_batch(vec![ SqliteBatchStatement { - sql: "INSERT OR REPLACE INTO _rivet_queue (id, name, body, created_at) VALUES (?, ?, ?, ?)" - .to_owned(), + sql: INSERT_QUEUE_MESSAGE_SQL.to_owned(), params: Some(vec![ BindParam::Integer(id), BindParam::Text(message.name.clone()), @@ -434,7 +385,7 @@ pub(crate) async fn persist_queue_message( ]), }, SqliteBatchStatement { - sql: "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET queue_next_id = excluded.queue_next_id".to_owned(), + sql: UPSERT_QUEUE_NEXT_ID_SQL.to_owned(), params: Some(vec![ BindParam::Null, BindParam::Null, @@ -457,8 +408,7 @@ pub(crate) async fn persist_queue_messages( let mut statements = Vec::with_capacity(chunk.len()); for (id, message) in chunk { statements.push(SqliteBatchStatement { - sql: "INSERT OR REPLACE INTO _rivet_queue (id, name, body, created_at) VALUES (?, ?, ?, ?)" - .to_owned(), + sql: INSERT_QUEUE_MESSAGE_SQL.to_owned(), params: Some(vec![ BindParam::Integer( i64::try_from(*id) @@ -505,7 +455,7 @@ fn split_queue_tx_chunks( pub(crate) async fn persist_queue_next_id(db: &SqliteDb, next_id: u64) -> Result<()> { let next_id = i64::try_from(next_id).context("queue next id exceeds sqlite integer range")?; db.execute( - "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET queue_next_id = excluded.queue_next_id", + UPSERT_QUEUE_NEXT_ID_SQL, Some(vec![ BindParam::Null, BindParam::Null, @@ -635,7 +585,7 @@ pub(crate) async fn user_kv_batch_put(db: &SqliteDb, entries: &[(&[u8], &[u8])]) let statements = chunk .iter() .map(|(key, value)| SqliteBatchStatement { - sql: "INSERT INTO _rivet_user_kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value".to_owned(), + sql: UPSERT_USER_KV_SQL.to_owned(), params: Some(vec![ BindParam::Blob((*key).to_vec()), BindParam::Blob((*value).to_vec()), @@ -736,7 +686,7 @@ pub(crate) async fn workflow_kv_batch_put(db: &SqliteDb, entries: &[(&[u8], &[u8 ); } statements.push(SqliteBatchStatement { - sql: "INSERT INTO _rivet_wf_kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value".to_owned(), + sql: UPSERT_WORKFLOW_KV_SQL.to_owned(), params: Some(vec![ BindParam::Blob((*key).to_vec()), BindParam::Blob((*value).to_vec()), @@ -764,7 +714,7 @@ fn build_workflow_kv_statements(writes: &[WorkflowKvWrite]) -> Result Result> pub(crate) async fn persist_last_pushed_alarm(db: &SqliteDb, alarm_ts: Option) -> Result<()> { db.execute( - "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET last_pushed_alarm = excluded.last_pushed_alarm", + UPSERT_LAST_PUSHED_ALARM_SQL, Some(vec![ optional_i64_param(alarm_ts), BindParam::Null, @@ -929,7 +879,7 @@ pub(crate) async fn load_inspector_token(db: &SqliteDb) -> Result pub(crate) async fn persist_inspector_token(db: &SqliteDb, token: &str) -> Result<()> { db.execute( - "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET inspector_token = excluded.inspector_token", + UPSERT_INSPECTOR_TOKEN_SQL, Some(vec![ BindParam::Null, BindParam::Text(token.to_owned()), @@ -964,7 +914,7 @@ pub(crate) async fn load_meta_text(db: &SqliteDb, key: &str) -> Result Result<()> { db.execute( - "INSERT INTO _rivet_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + UPSERT_META_TEXT_SQL, Some(vec![ BindParam::Text(key.to_owned()), BindParam::Blob(value.as_bytes().to_vec()), @@ -1026,7 +976,12 @@ async fn clear_table_bounded( loop { let rows = db .query( - clear_table_select_sql(table, key_column, size_expression), + clear_table_select_sql( + table, + key_column, + size_expression, + KV_TX_MAX_ROWS, + ), None, ) .await?; @@ -1187,5 +1142,5 @@ fn decode_cbor_blob( // Test shim keeps moved tests in crate-root tests/ with private-module access. #[cfg(test)] -#[path = "../../tests/internal_storage.rs"] +#[path = "../../../tests/internal_storage.rs"] pub(crate) mod tests; diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/queries.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/queries.rs new file mode 100644 index 0000000000..6c95891d80 --- /dev/null +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/queries.rs @@ -0,0 +1,112 @@ +//! SQL owned by RivetKit's internal actor storage. +//! +//! Every `SELECT`, `UPDATE`, or `DELETE` added or changed here must have +//! corresponding coverage in `sql_efficiency`. Simple inserts and schema DDL +//! require correctness or migration coverage instead. + +pub(crate) const LOAD_ACTOR_SNAPSHOT_SQL: &str = "SELECT a.has_initialized, a.input, s.state FROM _rivet_actor a JOIN _rivet_actor_state s ON s.id = a.id WHERE a.id = 1"; +pub(crate) const UPSERT_ACTOR_SQL: &str = "INSERT INTO _rivet_actor (id, has_initialized, input) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET has_initialized = excluded.has_initialized, input = excluded.input"; +pub(crate) const UPSERT_ACTOR_STATE_SQL: &str = "INSERT INTO _rivet_actor_state (id, state) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET state = excluded.state"; +pub(crate) const LOAD_CONNECTIONS_SQL: &str = "SELECT c.conn_id, c.parameters, s.state, s.subscriptions, c.gateway_id, c.request_id, s.server_message_index, s.client_message_index, c.request_path, c.request_headers FROM _rivet_conns c JOIN _rivet_conn_state s ON s.conn_id = c.conn_id ORDER BY c.conn_id"; +pub(crate) const INSERT_CONNECTION_SQL: &str = "INSERT OR IGNORE INTO _rivet_conns (conn_id, parameters, gateway_id, request_id, request_path, request_headers) VALUES (?, ?, ?, ?, ?, ?)"; +pub(crate) const UPSERT_CONNECTION_STATE_SQL: &str = "INSERT INTO _rivet_conn_state (conn_id, state, server_message_index, client_message_index, subscriptions) VALUES (?, ?, ?, ?, ?) ON CONFLICT(conn_id) DO UPDATE SET state = excluded.state, server_message_index = excluded.server_message_index, client_message_index = excluded.client_message_index, subscriptions = excluded.subscriptions"; +pub(crate) const DELETE_CONN_STATE_SQL: &str = + "DELETE FROM _rivet_conn_state WHERE conn_id = ?"; +pub(crate) const DELETE_CONN_SQL: &str = "DELETE FROM _rivet_conns WHERE conn_id = ?"; + +pub(crate) const RESET_SCHEDULES_FOR_LEGACY_IMPORT_SQL: &str = + "DELETE FROM _rivet_schedule_events"; +pub(crate) const INSERT_SCHEDULE_EVENT_SQL: &str = "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; +pub(crate) const UPSERT_RECURRING_SCHEDULE_SQL: &str = "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_id) DO UPDATE SET trigger_at = excluded.trigger_at, action = excluded.action, args = excluded.args, kind = excluded.kind, cron_expression = excluded.cron_expression, timezone = excluded.timezone, interval_ms = excluded.interval_ms, max_history = excluded.max_history"; +pub(crate) const CANCEL_SCHEDULE_SQL: &str = + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?"; +pub(crate) const GET_SCHEDULED_EVENT_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?"; +pub(crate) const LIST_SCHEDULED_EVENTS_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind = ? ORDER BY trigger_at, event_id"; +pub(crate) const DELETE_CRON_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND EXISTS (SELECT 1 FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?)"; +pub(crate) const DELETE_CRON_SQL: &str = + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?"; +pub(crate) const DELETE_CRON_IF_ACTION_SQL: &str = + "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ? AND action = ?"; +pub(crate) const LIST_CRONS_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind != ? ORDER BY trigger_at, event_id"; +pub(crate) const CRON_HISTORY_SQL: &str = "SELECT action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?"; +pub(crate) const LOAD_SCHEDULE_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ?"; +pub(crate) const COUNT_SCHEDULES_SQL: &str = "SELECT COUNT(*) FROM _rivet_schedule_events"; +pub(crate) const TAKE_DUE_SCHEDULES_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE trigger_at <= ? ORDER BY trigger_at, event_id"; +pub(crate) const ADVANCE_SKIPPED_SCHEDULE_SQL: &str = + "UPDATE _rivet_schedule_events SET trigger_at = ? WHERE event_id = ?"; +pub(crate) const ADVANCE_SCHEDULE_SQL: &str = + "UPDATE _rivet_schedule_events SET trigger_at = ?, last_started_at = ? WHERE event_id = ?"; +pub(crate) const INSERT_SCHEDULE_HISTORY_SQL: &str = "INSERT INTO _rivet_schedule_history (schedule_id, action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; +pub(crate) const FINISH_HISTORY_SQL: &str = "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE id = ? AND result = ?"; +pub(crate) const RECOVER_HISTORY_SQL: &str = "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE result = 0"; +pub(crate) const NEXT_FUTURE_SCHEDULE_SQL: &str = + "SELECT MIN(trigger_at) FROM _rivet_schedule_events WHERE trigger_at > ?"; +pub(crate) const NEXT_SCHEDULE_SQL: &str = + "SELECT MIN(trigger_at) FROM _rivet_schedule_events"; +pub(crate) const PRUNE_SCHEDULE_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND id NOT IN (SELECT id FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?)"; +pub(crate) const PRUNE_GLOBAL_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE id IN (SELECT id FROM _rivet_schedule_history ORDER BY fired_at DESC, id DESC LIMIT -1 OFFSET ?)"; + +pub(crate) fn claim_one_shots_sql(event_count: usize) -> String { + let placeholders = std::iter::repeat_n("(?, ?)", event_count) + .collect::>() + .join(", "); + format!( + "DELETE FROM _rivet_schedule_events WHERE kind = ? AND (event_id, trigger_at) IN ({placeholders})" + ) +} + +pub(crate) const LOAD_QUEUE_NEXT_ID_SQL: &str = + "SELECT queue_next_id FROM _rivet_runtime WHERE id = 1"; +pub(crate) const LOAD_QUEUE_STATS_SQL: &str = "SELECT COUNT(*), MAX(id) FROM _rivet_queue"; +pub(crate) const LOAD_QUEUE_MESSAGES_SQL: &str = + "SELECT id, name, body, created_at FROM _rivet_queue ORDER BY id"; +pub(crate) const INSERT_QUEUE_MESSAGE_SQL: &str = + "INSERT OR REPLACE INTO _rivet_queue (id, name, body, created_at) VALUES (?, ?, ?, ?)"; +pub(crate) const DELETE_QUEUE_MESSAGE_SQL: &str = "DELETE FROM _rivet_queue WHERE id = ?"; +pub(crate) const RESET_QUEUE_SQL: &str = "DELETE FROM _rivet_queue"; + +pub(crate) const DELETE_USER_KV_SQL: &str = "DELETE FROM _rivet_user_kv WHERE key = ?"; +pub(crate) const DELETE_USER_KV_RANGE_SQL: &str = + "DELETE FROM _rivet_user_kv WHERE key >= ? AND key < ?"; +pub(crate) const UPSERT_USER_KV_SQL: &str = "INSERT INTO _rivet_user_kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"; +pub(crate) const UPSERT_WORKFLOW_KV_SQL: &str = "INSERT INTO _rivet_wf_kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"; + +pub(crate) const LOAD_LAST_PUSHED_ALARM_SQL: &str = + "SELECT last_pushed_alarm FROM _rivet_runtime WHERE id = 1"; +pub(crate) const LOAD_INSPECTOR_TOKEN_SQL: &str = + "SELECT inspector_token FROM _rivet_runtime WHERE id = 1"; +pub(crate) const UPSERT_QUEUE_NEXT_ID_SQL: &str = "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET queue_next_id = excluded.queue_next_id"; +pub(crate) const UPSERT_LAST_PUSHED_ALARM_SQL: &str = "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET last_pushed_alarm = excluded.last_pushed_alarm"; +pub(crate) const UPSERT_INSPECTOR_TOKEN_SQL: &str = "INSERT INTO _rivet_runtime (id, last_pushed_alarm, inspector_token, queue_next_id) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET inspector_token = excluded.inspector_token"; +pub(crate) const LOAD_META_TEXT_SQL: &str = "SELECT value FROM _rivet_meta WHERE key = ?"; +pub(crate) const UPSERT_META_TEXT_SQL: &str = "INSERT INTO _rivet_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"; + +pub(crate) fn user_kv_batch_get_sql(key_count: usize) -> String { + let placeholders = std::iter::repeat_n("?", key_count) + .collect::>() + .join(", "); + format!("SELECT key, value FROM _rivet_user_kv WHERE key IN ({placeholders})") +} + +pub(crate) fn user_kv_list_sql(where_clause: &str, reverse: bool, limited: bool) -> String { + let order = if reverse { "DESC" } else { "ASC" }; + let limit_clause = if limited { " LIMIT ?" } else { "" }; + format!( + "SELECT key, value FROM _rivet_user_kv {where_clause} ORDER BY key {order}{limit_clause}" + ) +} + +pub(crate) fn clear_table_select_sql( + table: &str, + key_column: &str, + size_expression: &str, + max_rows: usize, +) -> String { + format!( + "SELECT {key_column}, {size_expression} FROM {table} ORDER BY {key_column} LIMIT {max_rows}" + ) +} + +pub(crate) fn clear_table_delete_sql(table: &str, key_column: &str) -> String { + format!("DELETE FROM {table} WHERE {key_column} = ?") +} diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/schema.rs similarity index 91% rename from rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs rename to rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/schema.rs index dd87bae2d8..f98121ee93 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/internal_schema.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/internal_storage/schema.rs @@ -1,12 +1,11 @@ use anyhow::{Context, Result, bail}; +use super::queries::{LOAD_META_TEXT_SQL, UPSERT_META_TEXT_SQL}; use crate::sqlite::{BindParam, ColumnValue, SqliteBatchStatement, SqliteDb}; pub(crate) const INTERNAL_SCHEMA_VERSION: i64 = 6; const SCHEMA_VERSION_KEY: &str = "schema_version"; -pub(crate) const READ_SCHEMA_VERSION_SQL: &str = - "SELECT value FROM _rivet_meta WHERE key = ?"; // `_rivet_meta` is the bootstrap root created before the numbered migrations. // `schema_version` cannot live in a table created by those migrations, and @@ -209,7 +208,7 @@ fn migration_statements(from_version: i64, to_version: i64) -> Result Result Result { let result = db .query( - READ_SCHEMA_VERSION_SQL, + LOAD_META_TEXT_SQL, Some(vec![BindParam::Text(SCHEMA_VERSION_KEY.to_owned())]), ) .await @@ -253,7 +252,28 @@ fn is_commit_too_large(error: &anyhow::Error) -> bool { .any(|cause| cause.to_string().contains("commit_too_large")) } +#[cfg(any(test, feature = "test-support"))] +pub(crate) fn initialize_test_schema(conn: &rusqlite::Connection) -> Result<()> { + conn.execute_batch(CREATE_META_TABLE) + .context("create test internal schema metadata table")?; + for migration in MIGRATIONS { + for sql in *migration { + conn.execute_batch(sql) + .context("apply test internal schema migration")?; + } + } + conn.execute( + UPSERT_META_TEXT_SQL, + rusqlite::params![ + SCHEMA_VERSION_KEY, + encode_schema_version(INTERNAL_SCHEMA_VERSION) + ], + ) + .context("record test internal schema version")?; + Ok(()) +} + // Test shim keeps moved tests in crate-root tests/ with private-module access. #[cfg(test)] -#[path = "../../tests/internal_schema.rs"] +#[path = "../../../tests/internal_schema.rs"] pub(crate) mod tests; diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs index 83be0ca298..1be4b52e8a 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/mod.rs @@ -9,7 +9,6 @@ pub mod connection; pub mod context; pub(crate) mod diagnostics; pub mod factory; -pub(crate) mod internal_schema; pub(crate) mod internal_storage; pub(crate) mod keys; pub mod kv; diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs index f6ae562d5d..6559ad453b 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/schedule.rs @@ -16,6 +16,7 @@ use tracing::Instrument; use uuid::Uuid; use crate::actor::context::ActorContext; +use crate::actor::internal_storage::queries::*; use crate::error::{ScheduleRuntimeError, client_error_message, client_error_metadata}; #[cfg(feature = "wasm-runtime")] use crate::runtime::RuntimeSpawner; @@ -37,41 +38,6 @@ pub(crate) const GLOBAL_HISTORY_PRUNE_INTERVAL: usize = 100; pub(crate) const GLOBAL_HISTORY_RETAINED_ROWS: i64 = MAX_ACTOR_HISTORY - GLOBAL_HISTORY_PRUNE_INTERVAL as i64; -pub(crate) const CANCEL_SCHEDULE_SQL: &str = - "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?"; -pub(crate) const GET_SCHEDULED_EVENT_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ? AND kind = ?"; -pub(crate) const LIST_SCHEDULED_EVENTS_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind = ? ORDER BY trigger_at, event_id"; -pub(crate) const DELETE_CRON_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND EXISTS (SELECT 1 FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?)"; -pub(crate) const DELETE_CRON_SQL: &str = - "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ?"; -pub(crate) const DELETE_CRON_IF_ACTION_SQL: &str = - "DELETE FROM _rivet_schedule_events WHERE event_id = ? AND kind != ? AND action = ?"; -pub(crate) const LIST_CRONS_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE kind != ? ORDER BY trigger_at, event_id"; -pub(crate) const CRON_HISTORY_SQL: &str = "SELECT action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?"; -pub(crate) const LOAD_SCHEDULE_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE event_id = ?"; -pub(crate) const COUNT_SCHEDULES_SQL: &str = "SELECT COUNT(*) FROM _rivet_schedule_events"; -pub(crate) const TAKE_DUE_SCHEDULES_SQL: &str = "SELECT event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history FROM _rivet_schedule_events WHERE trigger_at <= ? ORDER BY trigger_at, event_id"; -pub(crate) fn claim_one_shots_sql(event_count: usize) -> String { - let placeholders = std::iter::repeat_n("(?, ?)", event_count) - .collect::>() - .join(", "); - format!( - "DELETE FROM _rivet_schedule_events WHERE kind = ? AND (event_id, trigger_at) IN ({placeholders})" - ) -} -pub(crate) const ADVANCE_SKIPPED_SCHEDULE_SQL: &str = - "UPDATE _rivet_schedule_events SET trigger_at = ? WHERE event_id = ?"; -pub(crate) const ADVANCE_SCHEDULE_SQL: &str = - "UPDATE _rivet_schedule_events SET trigger_at = ?, last_started_at = ? WHERE event_id = ?"; -pub(crate) const FINISH_HISTORY_SQL: &str = "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE id = ? AND result = ?"; -pub(crate) const RECOVER_HISTORY_SQL: &str = "UPDATE _rivet_schedule_history SET finished_at = ?, result = ?, error_group = ?, error_code = ?, error_message = ?, error_metadata = ? WHERE result = 0"; -pub(crate) const NEXT_FUTURE_SCHEDULE_SQL: &str = - "SELECT MIN(trigger_at) FROM _rivet_schedule_events WHERE trigger_at > ?"; -pub(crate) const NEXT_SCHEDULE_SQL: &str = - "SELECT MIN(trigger_at) FROM _rivet_schedule_events"; -pub(crate) const PRUNE_SCHEDULE_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE schedule_id = ? AND id NOT IN (SELECT id FROM _rivet_schedule_history WHERE schedule_id = ? ORDER BY fired_at DESC, id DESC LIMIT ?)"; -pub(crate) const PRUNE_GLOBAL_HISTORY_SQL: &str = "DELETE FROM _rivet_schedule_history WHERE id IN (SELECT id FROM _rivet_schedule_history ORDER BY fired_at DESC, id DESC LIMIT -1 OFFSET ?)"; - pub(super) type InternalKeepAwakeCallback = Arc>) -> BoxFuture<'static, Result<()>> + Send + Sync>; pub(super) type LocalAlarmCallback = Arc BoxFuture<'static, ()> + Send + Sync>; @@ -226,7 +192,7 @@ impl ActorContext { let event_id = Uuid::new_v4().to_string(); self.sql() .execute( - "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + INSERT_SCHEDULE_EVENT_SQL, Some(vec![ BindParam::Text(event_id.clone()), BindParam::Integer(timestamp_ms), @@ -431,7 +397,7 @@ impl ActorContext { ) -> Result<()> { self.sql() .execute( - "INSERT INTO _rivet_schedule_events (event_id, trigger_at, action, args, kind, cron_expression, timezone, interval_ms, last_started_at, max_history) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(event_id) DO UPDATE SET trigger_at = excluded.trigger_at, action = excluded.action, args = excluded.args, kind = excluded.kind, cron_expression = excluded.cron_expression, timezone = excluded.timezone, interval_ms = excluded.interval_ms, max_history = excluded.max_history", + UPSERT_RECURRING_SCHEDULE_SQL, Some(vec![ BindParam::Text(event_id.to_owned()), BindParam::Integer(trigger_at), @@ -712,6 +678,9 @@ impl ActorContext { return Err(error).context("advance due recurring schedule"); } }; + if event.max_history > 0 { + self.record_schedule_history_inserted(); + } if is_running { continue; } @@ -822,11 +791,17 @@ impl ActorContext { fn should_prune_global_history(&self) -> bool { self.0 .schedule_history_insert_count - .fetch_add(1, Ordering::Relaxed) + .load(Ordering::Relaxed) % GLOBAL_HISTORY_PRUNE_INTERVAL == 0 } + fn record_schedule_history_inserted(&self) { + self.0 + .schedule_history_insert_count + .fetch_add(1, Ordering::Relaxed); + } + fn mark_schedule_dirty(&self) { self.0 .schedule_dirty_since_push @@ -1192,7 +1167,7 @@ fn append_history_statements( } let index = statements.len(); statements.push(SqliteBatchStatement { - sql: "INSERT INTO _rivet_schedule_history (schedule_id, action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)".to_owned(), + sql: INSERT_SCHEDULE_HISTORY_SQL.to_owned(), params: Some(vec![ BindParam::Text(event.event_id.clone()), BindParam::Text(event.action.clone()), diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs index 705328e27a..0c0a53cbdf 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs @@ -1148,7 +1148,7 @@ impl ActorTask { self.ctx.configure_actor_events(self.actor_event_tx.clone()); let schema_started_at = Instant::now(); - crate::actor::internal_schema::ensure_internal_schema(self.ctx.sql()) + crate::actor::internal_storage::schema::ensure_internal_schema(self.ctx.sql()) .await .context("initialize internal sqlite schema")?; tracing::debug!( diff --git a/rivetkit-rust/packages/rivetkit-core/src/testing.rs b/rivetkit-rust/packages/rivetkit-core/src/testing.rs index 048364a3f2..027c960232 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/testing.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/testing.rs @@ -204,7 +204,7 @@ fn spawn_remote_sqlite(mut receiver: mpsc::UnboundedReceiver) { std::thread::spawn(move || { let conn = rusqlite::Connection::open_in_memory().expect("test sqlite connection should open"); - conn.execute_batch(TEST_INTERNAL_SCHEMA_SQL) + crate::actor::internal_storage::schema::initialize_test_schema(&conn) .expect("test sqlite schema should initialize"); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -373,89 +373,3 @@ fn sqlite_column_value(value: ValueRef<'_>) -> protocol::SqliteColumnValue { } } -const TEST_INTERNAL_SCHEMA_SQL: &str = r#" -CREATE TABLE _rivet_meta ( - key TEXT PRIMARY KEY, - value BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE _rivet_runtime ( - id INTEGER PRIMARY KEY CHECK (id = 1), - last_pushed_alarm INTEGER, - inspector_token TEXT, - queue_next_id INTEGER NOT NULL -) STRICT; -CREATE TABLE _rivet_actor ( - id INTEGER PRIMARY KEY CHECK (id = 1), - has_initialized INTEGER NOT NULL, - input BLOB -) STRICT; -CREATE TABLE _rivet_actor_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - state BLOB NOT NULL -) STRICT; -CREATE TABLE _rivet_schedule_events ( - event_id TEXT PRIMARY KEY, - trigger_at INTEGER NOT NULL, - action TEXT NOT NULL, - args BLOB, - kind INTEGER NOT NULL, - cron_expression TEXT, - timezone TEXT, - interval_ms INTEGER, - last_started_at INTEGER, - max_history INTEGER NOT NULL -) STRICT, WITHOUT ROWID; -CREATE INDEX _rivet_schedule_events_trigger_at - ON _rivet_schedule_events (trigger_at); -CREATE TABLE _rivet_schedule_history ( - id INTEGER PRIMARY KEY, - schedule_id TEXT NOT NULL, - action TEXT NOT NULL, - scheduled_at INTEGER NOT NULL, - fired_at INTEGER NOT NULL, - finished_at INTEGER, - result INTEGER NOT NULL, - error_group TEXT, - error_code TEXT, - error_message TEXT, - error_metadata BLOB -) STRICT; -CREATE INDEX _rivet_schedule_history_schedule - ON _rivet_schedule_history (schedule_id, fired_at DESC, id DESC); -CREATE INDEX _rivet_schedule_history_fired_at - ON _rivet_schedule_history (fired_at DESC, id DESC); -CREATE INDEX _rivet_schedule_history_running - ON _rivet_schedule_history (result) - WHERE result = 0; -CREATE TABLE _rivet_conns ( - conn_id TEXT PRIMARY KEY, - parameters BLOB NOT NULL, - gateway_id BLOB NOT NULL, - request_id BLOB NOT NULL, - request_path TEXT NOT NULL, - request_headers BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE _rivet_conn_state ( - conn_id TEXT PRIMARY KEY, - state BLOB NOT NULL, - server_message_index INTEGER NOT NULL, - client_message_index INTEGER NOT NULL, - subscriptions BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE _rivet_queue ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - body BLOB NOT NULL, - created_at INTEGER NOT NULL -) STRICT; -CREATE TABLE _rivet_wf_kv ( - key BLOB PRIMARY KEY, - value BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE _rivet_user_kv ( - key BLOB PRIMARY KEY, - value BLOB NOT NULL -) STRICT, WITHOUT ROWID; -INSERT INTO _rivet_meta (key, value) -VALUES ('schema_version', x'0600000000000000'); -"#; diff --git a/rivetkit-rust/packages/rivetkit-core/tests/context.rs b/rivetkit-rust/packages/rivetkit-core/tests/context.rs index de0c475610..9a42b3589a 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/context.rs @@ -23,7 +23,6 @@ use rivet_envoy_client::sqlite::{ use rusqlite::types::{Value as TestSqliteValue, ValueRef as TestSqliteValueRef}; use tokio::sync::mpsc; -use crate::sqlite::ColumnValue; struct TestIdleEnvoyCallbacks; @@ -200,7 +199,7 @@ pub(crate) struct TestSqliteWriteGate { fn open_test_sqlite_connection_with_schema() -> rusqlite::Connection { let conn = rusqlite::Connection::open_in_memory().expect("test sqlite connection should open"); - conn.execute_batch(TEST_INTERNAL_SCHEMA_SQL) + crate::actor::internal_storage::schema::initialize_test_schema(&conn) .expect("test sqlite internal schema should initialize"); conn } @@ -271,208 +270,6 @@ fn spawn_test_remote_sqlite_on( }); } -// Hand-maintained copy of the internal schema so synchronous test fixtures can -// initialize an in-memory database without driving the async migration ladder. -// `test_internal_schema_sql_matches_real_initializer` guards this copy against -// drifting from `internal_schema::ensure_internal_schema`. -pub(crate) const TEST_INTERNAL_SCHEMA_SQL: &str = r#" -CREATE TABLE IF NOT EXISTS _rivet_meta ( - key TEXT PRIMARY KEY, - value BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE IF NOT EXISTS _rivet_runtime ( - id INTEGER PRIMARY KEY CHECK (id = 1), - last_pushed_alarm INTEGER, - inspector_token TEXT, - queue_next_id INTEGER NOT NULL -) STRICT; -CREATE TABLE IF NOT EXISTS _rivet_actor ( - id INTEGER PRIMARY KEY CHECK (id = 1), - has_initialized INTEGER NOT NULL, - input BLOB -) STRICT; -CREATE TABLE IF NOT EXISTS _rivet_actor_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - state BLOB NOT NULL -) STRICT; -CREATE TABLE IF NOT EXISTS _rivet_schedule_events ( - event_id TEXT PRIMARY KEY, - trigger_at INTEGER NOT NULL, - action TEXT NOT NULL, - args BLOB, - kind INTEGER NOT NULL, - cron_expression TEXT, - timezone TEXT, - interval_ms INTEGER, - last_started_at INTEGER, - max_history INTEGER NOT NULL -) STRICT, WITHOUT ROWID; -CREATE INDEX IF NOT EXISTS _rivet_schedule_events_trigger_at - ON _rivet_schedule_events (trigger_at); -CREATE TABLE IF NOT EXISTS _rivet_schedule_history ( - id INTEGER PRIMARY KEY, - schedule_id TEXT NOT NULL, - action TEXT NOT NULL, - scheduled_at INTEGER NOT NULL, - fired_at INTEGER NOT NULL, - finished_at INTEGER, - result INTEGER NOT NULL, - error_group TEXT, - error_code TEXT, - error_message TEXT, - error_metadata BLOB -) STRICT; -CREATE INDEX IF NOT EXISTS _rivet_schedule_history_schedule - ON _rivet_schedule_history (schedule_id, fired_at DESC, id DESC); -CREATE INDEX IF NOT EXISTS _rivet_schedule_history_fired_at - ON _rivet_schedule_history (fired_at DESC, id DESC); -CREATE INDEX IF NOT EXISTS _rivet_schedule_history_running - ON _rivet_schedule_history (result) - WHERE result = 0; -CREATE TABLE IF NOT EXISTS _rivet_conns ( - conn_id TEXT PRIMARY KEY, - parameters BLOB NOT NULL, - gateway_id BLOB NOT NULL, - request_id BLOB NOT NULL, - request_path TEXT NOT NULL, - request_headers BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE IF NOT EXISTS _rivet_conn_state ( - conn_id TEXT PRIMARY KEY, - state BLOB NOT NULL, - server_message_index INTEGER NOT NULL, - client_message_index INTEGER NOT NULL, - subscriptions BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE IF NOT EXISTS _rivet_queue ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - body BLOB NOT NULL, - created_at INTEGER NOT NULL -) STRICT; -CREATE TABLE IF NOT EXISTS _rivet_wf_kv ( - key BLOB PRIMARY KEY, - value BLOB NOT NULL -) STRICT, WITHOUT ROWID; -CREATE TABLE IF NOT EXISTS _rivet_user_kv ( - key BLOB PRIMARY KEY, - value BLOB NOT NULL -) STRICT, WITHOUT ROWID; -INSERT INTO _rivet_meta (key, value) -VALUES ('schema_version', x'0600000000000000') -ON CONFLICT(key) DO UPDATE SET value = excluded.value; -"#; - -/// Strips SQL comments and `IF NOT EXISTS`, then collapses whitespace so DDL -/// from `sqlite_master` compares structurally instead of textually. -fn normalize_schema_sql(sql: &str) -> String { - sql.lines() - .map(|line| match line.find("--") { - Some(index) => &line[..index], - None => line, - }) - .collect::>() - .join(" ") - .replace("IF NOT EXISTS ", "") - .split_whitespace() - .collect::>() - .join(" ") -} - -fn dump_local_schema(conn: &rusqlite::Connection) -> Vec<(String, String)> { - let mut statement = conn - .prepare("SELECT name, COALESCE(sql, '') FROM sqlite_master ORDER BY name") - .expect("sqlite_master query should prepare"); - let rows = statement - .query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .expect("sqlite_master query should run") - .collect::, _>>() - .expect("sqlite_master rows should decode"); - rows.into_iter() - .map(|(name, sql)| (name, normalize_schema_sql(&sql))) - .collect() -} - -// The test schema constant above is a hand-maintained copy, so this guard -// initializes one database through the real production migration ladder and -// asserts the resulting schema objects and recorded schema version match the -// copy exactly. -#[tokio::test] -async fn test_internal_schema_sql_matches_real_initializer() { - // Initialize an empty database through the production ladder over the - // remote sqlite protocol. - let (sql_handle, sql_rx) = test_envoy_handle(); - let real_conn = - rusqlite::Connection::open_in_memory().expect("real sqlite connection should open"); - spawn_test_remote_sqlite_on(real_conn, sql_rx, None); - let db = SqliteDb::new_with_remote_sqlite( - sql_handle, - "schema-drift-real".to_owned(), - None, - Some(1), - false, - true, - ) - .expect("test remote sqlite should be configured"); - crate::actor::internal_schema::ensure_internal_schema(&db) - .await - .expect("real internal schema initializer should succeed"); - - let real_schema_result = db - .query( - "SELECT name, COALESCE(sql, '') FROM sqlite_master ORDER BY name", - None, - ) - .await - .expect("real sqlite_master query should succeed"); - let real_schema: Vec<(String, String)> = real_schema_result - .rows - .iter() - .map(|row| { - let [ColumnValue::Text(name), ColumnValue::Text(sql)] = row.as_slice() else { - panic!("sqlite_master row should be two text columns, got {row:?}"); - }; - (name.clone(), normalize_schema_sql(sql)) - }) - .collect(); - - let real_version_result = db - .query( - "SELECT value FROM _rivet_meta WHERE key = 'schema_version'", - None, - ) - .await - .expect("real schema version query should succeed"); - let [ColumnValue::Blob(real_version)] = real_version_result.rows[0].as_slice() else { - panic!( - "schema version should be a blob, got {:?}", - real_version_result.rows - ); - }; - - // Initialize a second database from the hand-maintained test copy. - let test_conn = open_test_sqlite_connection_with_schema(); - let test_schema = dump_local_schema(&test_conn); - let test_version: Vec = test_conn - .query_row( - "SELECT value FROM _rivet_meta WHERE key = 'schema_version'", - [], - |row| row.get(0), - ) - .expect("test schema version should load"); - - assert_eq!( - test_schema, real_schema, - "TEST_INTERNAL_SCHEMA_SQL drifted from internal_schema::ensure_internal_schema" - ); - assert_eq!( - &test_version, real_version, - "TEST_INTERNAL_SCHEMA_SQL schema_version drifted from the real migration ladder" - ); -} - fn execute_test_sqlite( conn: &rusqlite::Connection, request: protocol::SqliteExecuteRequest, diff --git a/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs b/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs index a29cb38448..b78b321683 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/migrate_kv_to_sqlite.rs @@ -22,8 +22,8 @@ use tokio::sync::{Mutex as AsyncMutex, mpsc}; use crate::actor::connection::{PersistedConnection, encode_persisted_connection}; use crate::actor::context::ActorContext; -use crate::actor::internal_schema; use crate::actor::internal_storage::{self, InternalActorSnapshot}; +use crate::actor::internal_storage::schema as internal_schema; use crate::actor::keys::{ INSPECTOR_TOKEN_KEY, KV_PREFIX, LAST_PUSHED_ALARM_KEY, PERSIST_DATA_KEY, QUEUE_METADATA_KEY, make_connection_key, make_prefixed_key, make_queue_message_key, make_traces_key, diff --git a/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs b/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs index d7310ea320..2d662b9878 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/schedule.rs @@ -636,12 +636,57 @@ mod moved_tests { async fn global_history_pruning_is_periodic() { let ctx = context("actor-periodic-global-history-prune"); assert!(ctx.should_prune_global_history()); + ctx.record_schedule_history_inserted(); for _ in 1..GLOBAL_HISTORY_PRUNE_INTERVAL { assert!(!ctx.should_prune_global_history()); + ctx.record_schedule_history_inserted(); } assert!(ctx.should_prune_global_history()); } + #[tokio::test] + async fn failed_history_insert_keeps_global_pruning_due() { + let ctx = context("actor-failed-global-history-prune"); + ctx.sql() + .execute( + "WITH RECURSIVE n(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 9999) INSERT INTO _rivet_schedule_history (schedule_id, action, scheduled_at, fired_at, finished_at, result, error_group, error_code, error_message, error_metadata) SELECT 'cron:seed', 'seed', value, value, value, 1, NULL, NULL, NULL, NULL FROM n", + None, + ) + .await + .unwrap(); + ctx.cron_every("tick", 5_000, "tick", &[], Some(1)) + .await + .unwrap(); + ctx.sql() + .execute( + "CREATE TRIGGER fail_schedule_history_insert BEFORE INSERT ON _rivet_schedule_history BEGIN SELECT RAISE(FAIL, 'injected history failure'); END;", + None, + ) + .await + .unwrap(); + ctx.set_schedule_time_for_tests(BASE_TIME + 5_000); + assert!(ctx.take_due_schedule_dispatches().await.is_err()); + assert!(ctx.should_prune_global_history()); + + ctx.sql() + .execute("DROP TRIGGER fail_schedule_history_insert;", None) + .await + .unwrap(); + let dispatch = ctx.take_due_schedule_dispatches().await.unwrap().remove(0); + ctx.finish_schedule_dispatch(&dispatch.event_id, dispatch.history_id, None) + .await; + + let count = ctx + .sql() + .query("SELECT COUNT(*) FROM _rivet_schedule_history", None) + .await + .unwrap(); + assert_eq!( + count.rows, + vec![vec![crate::sqlite::ColumnValue::Integer(9_900)]] + ); + } + #[tokio::test] async fn running_history_is_recovered_as_interrupted() { let ctx = context("actor-interrupted"); diff --git a/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs b/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs index 47b879e9e9..a69a6c913d 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/sql_efficiency.rs @@ -3,8 +3,9 @@ use std::fmt::Write as _; use rusqlite::types::Value; use rusqlite::{Connection, StatementStatus, params_from_iter}; -use crate::actor::internal_schema::{CREATE_META_TABLE, MIGRATIONS, READ_SCHEMA_VERSION_SQL}; use crate::actor::{internal_storage, schedule}; +use crate::actor::internal_storage::queries; +use crate::actor::internal_storage::schema::{CREATE_META_TABLE, MIGRATIONS}; use crate::types::ListOpts; // This suite covers SQL owned by rivetkit-core. User-provided SQL, including @@ -284,28 +285,27 @@ fn query_catalog() -> Vec { QueryCase { id: "runtime.last_alarm", sql: internal_storage::LOAD_LAST_PUSHED_ALARM_SQL.into(), params: vec![], expectation: indexed(None, &["_rivet_runtime"]) }, QueryCase { id: "runtime.inspector_token", sql: internal_storage::LOAD_INSPECTOR_TOKEN_SQL.into(), params: vec![], expectation: indexed(None, &["_rivet_runtime"]) }, QueryCase { id: "meta.get", sql: internal_storage::LOAD_META_TEXT_SQL.into(), params: vec![text("fixture")], expectation: indexed(None, &["_rivet_meta"]) }, - QueryCase { id: "schema.version", sql: READ_SCHEMA_VERSION_SQL.into(), params: vec![text("schema_version")], expectation: indexed(None, &["_rivet_meta"]) }, - QueryCase { id: "schedule.cancel", sql: schedule::CANCEL_SCHEDULE_SQL.into(), params: vec![text("at:00000000"), 0_i64.into()], expectation: indexed(None, all_schedules) }, - QueryCase { id: "schedule.get_one_shot", sql: schedule::GET_SCHEDULED_EVENT_SQL.into(), params: vec![text("at:00000000"), 0_i64.into()], expectation: indexed(None, all_schedules) }, - QueryCase { id: "schedule.list_one_shots", sql: schedule::LIST_SCHEDULED_EVENTS_SQL.into(), params: vec![0_i64.into()], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "the API enumerates all one-shot schedules in trigger order", bound: "ActorConfig.max_schedules caps all schedules (default 1,000)" }]) }, - QueryCase { id: "schedule.delete_cron_history", sql: schedule::DELETE_CRON_HISTORY_SQL.into(), params: vec![text("cron:00000001"), text("cron:00000001"), 0_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, - QueryCase { id: "schedule.delete_cron", sql: schedule::DELETE_CRON_SQL.into(), params: vec![text("cron:00000001"), 0_i64.into()], expectation: indexed(None, all_schedules) }, - QueryCase { id: "schedule.delete_cron_if_action", sql: schedule::DELETE_CRON_IF_ACTION_SQL.into(), params: vec![text("cron:00000001"), 0_i64.into(), text("run")], expectation: indexed(None, all_schedules) }, - QueryCase { id: "schedule.list_recurring", sql: schedule::LIST_CRONS_SQL.into(), params: vec![0_i64.into()], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "the API enumerates all recurring schedules in trigger order", bound: "ActorConfig.max_schedules caps all schedules (default 1,000)" }]) }, - QueryCase { id: "schedule.history", sql: schedule::CRON_HISTORY_SQL.into(), params: vec![text("cron:0001"), 20_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, - QueryCase { id: "schedule.get", sql: schedule::LOAD_SCHEDULE_SQL.into(), params: vec![text("cron:00000001")], expectation: indexed(None, all_schedules) }, - QueryCase { id: "schedule.count", sql: schedule::COUNT_SCHEDULES_SQL.into(), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "SQLite represents its optimized COUNT(*) opcode as a covering-index scan in the query plan", bound: "runtime VM-step scaling verifies row-count-independent work; ActorConfig.max_schedules also caps rows" }]) }, - QueryCase { id: "schedule.due", sql: schedule::TAKE_DUE_SCHEDULES_SQL.into(), params: vec![5_i64.into()], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, - QueryCase { id: "schedule.claim_one_shots", sql: schedule::claim_one_shots_sql(3), params: vec![0_i64.into(), text("at:00000000"), 0_i64.into(), text("at:00000003"), 3_i64.into(), text("at:00000006"), 6_i64.into()], expectation: indexed(None, all_schedules) }, - QueryCase { id: "schedule.advance_skipped", sql: schedule::ADVANCE_SKIPPED_SCHEDULE_SQL.into(), params: vec![20_000_i64.into(), text("cron:00000001")], expectation: indexed(None, all_schedules) }, - QueryCase { id: "schedule.advance", sql: schedule::ADVANCE_SCHEDULE_SQL.into(), params: vec![20_000_i64.into(), 10_000_i64.into(), text("cron:00000002")], expectation: indexed(None, all_schedules) }, - QueryCase { id: "schedule.finish_history", sql: schedule::FINISH_HISTORY_SQL.into(), params: vec![20_000_i64.into(), 1_i64.into(), Value::Null, Value::Null, Value::Null, Value::Null, 1_i64.into(), 0_i64.into()], expectation: indexed(None, all_history) }, - QueryCase { id: "schedule.recover_history", sql: schedule::RECOVER_HISTORY_SQL.into(), params: vec![20_000_i64.into(), 2_i64.into(), text("schedule"), text("interrupted"), text("interrupted"), Value::Null], expectation: indexed(Some("_rivet_schedule_history_running"), all_history) }, - QueryCase { id: "schedule.next_future", sql: schedule::NEXT_FUTURE_SCHEDULE_SQL.into(), params: vec![5_000_i64.into()], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, - QueryCase { id: "schedule.next", sql: schedule::NEXT_SCHEDULE_SQL.into(), params: vec![], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, - QueryCase { id: "schedule.prune", sql: schedule::PRUNE_SCHEDULE_HISTORY_SQL.into(), params: vec![text("cron:0001"), text("cron:0001"), 20_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, - QueryCase { id: "schedule.prune_global", sql: schedule::PRUNE_GLOBAL_HISTORY_SQL.into(), params: vec![schedule::GLOBAL_HISTORY_RETAINED_ROWS.into()], expectation: QueryPlanExpectation { forbid_full_scan_of: all_history, forbid_temp_sort: true, forbid_auto_index: true, expected_index: Some("_rivet_schedule_history_fired_at"), allowed_scans: &[AllowedScan { table: "_rivet_schedule_history", reason: "global pruning walks the history ordering index to skip retained rows and delete only the overflow", bound: "periodic pruning retains 9,900 rows, leaving headroom beneath MAX_ACTOR_HISTORY" }] } }, - QueryCase { id: "cleanup.select_chunk", sql: internal_storage::clear_table_select_sql("_rivet_user_kv", "key", "length(key) + length(value)"), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_user_kv", reason: "interrupted legacy import cleanup walks one primary-key chunk", bound: "each statement is limited to KV_TX_MAX_ROWS (128)" }]) }, + QueryCase { id: "schedule.cancel", sql: queries::CANCEL_SCHEDULE_SQL.into(), params: vec![text("at:00000000"), 0_i64.into()], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.get_one_shot", sql: queries::GET_SCHEDULED_EVENT_SQL.into(), params: vec![text("at:00000000"), 0_i64.into()], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.list_one_shots", sql: queries::LIST_SCHEDULED_EVENTS_SQL.into(), params: vec![0_i64.into()], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "the API enumerates all one-shot schedules in trigger order", bound: "ActorConfig.max_schedules caps all schedules (default 1,000)" }]) }, + QueryCase { id: "schedule.delete_cron_history", sql: queries::DELETE_CRON_HISTORY_SQL.into(), params: vec![text("cron:00000001"), text("cron:00000001"), 0_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, + QueryCase { id: "schedule.delete_cron", sql: queries::DELETE_CRON_SQL.into(), params: vec![text("cron:00000001"), 0_i64.into()], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.delete_cron_if_action", sql: queries::DELETE_CRON_IF_ACTION_SQL.into(), params: vec![text("cron:00000001"), 0_i64.into(), text("run")], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.list_recurring", sql: queries::LIST_CRONS_SQL.into(), params: vec![0_i64.into()], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "the API enumerates all recurring schedules in trigger order", bound: "ActorConfig.max_schedules caps all schedules (default 1,000)" }]) }, + QueryCase { id: "schedule.history", sql: queries::CRON_HISTORY_SQL.into(), params: vec![text("cron:0001"), 20_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, + QueryCase { id: "schedule.get", sql: queries::LOAD_SCHEDULE_SQL.into(), params: vec![text("cron:00000001")], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.count", sql: queries::COUNT_SCHEDULES_SQL.into(), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_schedule_events", reason: "SQLite represents its optimized COUNT(*) opcode as a covering-index scan in the query plan", bound: "runtime VM-step scaling verifies row-count-independent work; ActorConfig.max_schedules also caps rows" }]) }, + QueryCase { id: "schedule.due", sql: queries::TAKE_DUE_SCHEDULES_SQL.into(), params: vec![5_i64.into()], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, + QueryCase { id: "schedule.claim_one_shots", sql: queries::claim_one_shots_sql(3), params: vec![0_i64.into(), text("at:00000000"), 0_i64.into(), text("at:00000003"), 3_i64.into(), text("at:00000006"), 6_i64.into()], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.advance_skipped", sql: queries::ADVANCE_SKIPPED_SCHEDULE_SQL.into(), params: vec![20_000_i64.into(), text("cron:00000001")], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.advance", sql: queries::ADVANCE_SCHEDULE_SQL.into(), params: vec![20_000_i64.into(), 10_000_i64.into(), text("cron:00000002")], expectation: indexed(None, all_schedules) }, + QueryCase { id: "schedule.finish_history", sql: queries::FINISH_HISTORY_SQL.into(), params: vec![20_000_i64.into(), 1_i64.into(), Value::Null, Value::Null, Value::Null, Value::Null, 1_i64.into(), 0_i64.into()], expectation: indexed(None, all_history) }, + QueryCase { id: "schedule.recover_history", sql: queries::RECOVER_HISTORY_SQL.into(), params: vec![20_000_i64.into(), 2_i64.into(), text("schedule"), text("interrupted"), text("interrupted"), Value::Null], expectation: indexed(Some("_rivet_schedule_history_running"), all_history) }, + QueryCase { id: "schedule.next_future", sql: queries::NEXT_FUTURE_SCHEDULE_SQL.into(), params: vec![5_000_i64.into()], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, + QueryCase { id: "schedule.next", sql: queries::NEXT_SCHEDULE_SQL.into(), params: vec![], expectation: indexed(Some("_rivet_schedule_events_trigger_at"), all_schedules) }, + QueryCase { id: "schedule.prune", sql: queries::PRUNE_SCHEDULE_HISTORY_SQL.into(), params: vec![text("cron:0001"), text("cron:0001"), 20_i64.into()], expectation: indexed(Some("_rivet_schedule_history_schedule"), all_history) }, + QueryCase { id: "schedule.prune_global", sql: queries::PRUNE_GLOBAL_HISTORY_SQL.into(), params: vec![schedule::GLOBAL_HISTORY_RETAINED_ROWS.into()], expectation: QueryPlanExpectation { forbid_full_scan_of: all_history, forbid_temp_sort: true, forbid_auto_index: true, expected_index: Some("_rivet_schedule_history_fired_at"), allowed_scans: &[AllowedScan { table: "_rivet_schedule_history", reason: "global pruning walks the history ordering index to skip retained rows and delete only the overflow", bound: "periodic pruning retains 9,900 rows, leaving headroom beneath MAX_ACTOR_HISTORY" }] } }, + QueryCase { id: "cleanup.select_chunk", sql: internal_storage::clear_table_select_sql("_rivet_user_kv", "key", "length(key) + length(value)", internal_storage::KV_TX_MAX_ROWS), params: vec![], expectation: bounded_scan(&[AllowedScan { table: "_rivet_user_kv", reason: "interrupted legacy import cleanup walks one primary-key chunk", bound: "each statement is limited to KV_TX_MAX_ROWS (128)" }]) }, QueryCase { id: "cleanup.delete_row", sql: internal_storage::clear_table_delete_sql("_rivet_user_kv", "key"), params: vec![Value::Blob(b"00000001".to_vec())], expectation: indexed(None, all_user_kv) }, ] } @@ -370,12 +370,12 @@ fn sql_efficiency_hot_paths_do_not_scale_with_unrelated_rows() { ), ( "schedule.get", - schedule::LOAD_SCHEDULE_SQL.to_owned(), + queries::LOAD_SCHEDULE_SQL.to_owned(), vec![Value::Text("cron:00000050".to_owned())], ), ( "schedule.due", - schedule::TAKE_DUE_SCHEDULES_SQL.to_owned(), + queries::TAKE_DUE_SCHEDULES_SQL.to_owned(), vec![Value::Integer(5)], ), ]; @@ -409,7 +409,7 @@ fn sql_efficiency_negative_control_detects_unindexed_access() { #[tokio::test] async fn sql_efficiency_unlimited_kv_listing_uses_one_ordered_query() { let ctx = crate::testing::actor_context("sql-efficiency-list", "actor", Vec::new(), "local"); - crate::actor::internal_schema::ensure_internal_schema(ctx.sql()) + crate::actor::internal_storage::schema::ensure_internal_schema(ctx.sql()) .await .expect("initialize KV listing fixture"); let entries = (0..300) diff --git a/rivetkit-rust/packages/rivetkit-core/tests/task.rs b/rivetkit-rust/packages/rivetkit-core/tests/task.rs index d7e620c3d8..a0f082a48c 100644 --- a/rivetkit-rust/packages/rivetkit-core/tests/task.rs +++ b/rivetkit-rust/packages/rivetkit-core/tests/task.rs @@ -433,7 +433,7 @@ pub(crate) mod moved_tests { .or_insert_with(|| { let conn = rusqlite::Connection::open_in_memory() .expect("test sqlite connection should open"); - conn.execute_batch(crate::actor::context::tests::TEST_INTERNAL_SCHEMA_SQL) + crate::actor::internal_storage::schema::initialize_test_schema(&conn) .expect("test sqlite internal schema should initialize"); Arc::new(Mutex::new(conn)) })