From b50ef9aadfb923e75493143b9be5d82d428ced70 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 19 Jul 2026 05:55:45 -0700 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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.