SpacetimeDB 调度表(Schedule Tables)完整指南:用 scheduled 列触发定时 Reducer 与 Procedure
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
本篇指南以 SpacetimeDB 官方核心概念文档《Schedule Tables》为主体,围绕"调度表"这一把表行与定时执行绑定起来的特殊机制展开:如何通过
scheduleAt/ScheduleAt列在指定时间或固定间隔触发 reducer 或 procedure,并深入仓库源码(crates/core/src/host/scheduler.rs、crates/lib/src/scheduler.rs、crates/datastore/src/system_tables.rs等)剖析调度队列、interval 补偿、行生命周期等底层实现。读完你将能在 TypeScript、C#、Rust、C++ 四种服务端语言中完整实现"定时提醒、内容过期、周期任务、游戏计时"等能力。
调度表(Schedule Table)是 SpacetimeDB 中一类特殊的表:只要表中包含一个类型为ScheduleAt的调度列,向该表插入的行就会被 SpacetimeDB 的调度器接管,在约定的时间点自动调用与之绑定的 reducer 或 procedure。这让"发送提醒、过期条目、延迟动作、周期性维护、游戏计时事件"等未来动作无需外部定时器即可在数据库内原生于线完成。
:::tip 调度 Procedure Procedure 与 reducer 使用完全相同的调度模式:TypeScript 中传入onSchedule选项,其他语言则在scheduled属性中引用 procedure 名称。当定时任务需要发起 HTTP 请求或执行其他副作用时,调度 procedure 尤其有用,参见 Scheduling Procedures 中的示例。 :::
定义一张调度表
定义调度表的核心是两步:声明一个带ScheduleAt类型列的表,再把这张表与某个 reducer/procedure 建立绑定关系。
:::note 为什么代码里叫 "scheduled"? 表属性使用scheduled(带 "d"),因为它指的是被调度的 reducer——即将被调度执行的那个函数。表本身叫 "schedule table"(调度表,存储调度计划),而它触发的 reducer 叫 "scheduled reducer"(被调度的 reducer)。 :::
TypeScript:onSchedule绑定
在 TypeScript 中,绑定声明在 reducer 一侧,使用onSchedule选项:
import { schema, table, t } from 'spacetimedb/server'; const reminder = table( { name: 'reminder' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), message: t.string(), } ); const spacetimedb = schema({ reminder }); export default spacetimedb; export const sendReminder = spacetimedb.reducer( { onSchedule: reminder }, { arg: reminder.rowType }, (_ctx, { arg }) => { // Invoked automatically by the scheduler // arg.message, arg.scheduledAt, arg.scheduledId } );onSchedule将 reducer 注册为这张调度表的目标函数。由于表定义并不反向引用 reducer,表与 reducer 可以拆分到不同文件而不会产生循环导入。一张调度表最多只能绑定一个 reducer 或 procedure,绑定第二个会在 schema 校验阶段直接报错(对应 Rust 宏中的错误提示 "can only specify one scheduled reducer or procedure",见 crates/bindings-macro/src/table.rs)。
同样的选项也适用于 procedure,前提是 procedure 的返回类型为t.unit()。见 Scheduling Procedures。
:::note 遗留的scheduled表选项 旧代码把绑定声明在表上,通过一个前向引用的 thunk 指向被调度的 reducer:
const reminder = table( { name: 'reminder', scheduled: (): any => sendReminder }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), message: t.string(), } ); export const sendReminder = spacetimedb.reducer({ arg: reminder.rowType }, (_ctx, { arg }) => { // Invoked automatically by the scheduler });这种写法仍然可用,但前向引用迫使表与 reducer 位于同一文件,并且破坏了类型推断(因此需要(): any =>强转)。新代码请优先使用onSchedule。 :::
C#:Scheduled与ScheduledAt属性
C# 通过[SpacetimeDB.Table]特性声明调度关系:
:::tip C# 调度列 在[SpacetimeDB.Table(..., ScheduledAt = "...")]中,ScheduledAt的值必须与该表上某个字段名完全一致,且该字段类型必须是ScheduleAt(例如"ScheduledAt"或"scheduled_at")。 :::
using SpacetimeDB; public static partial class Module { [SpacetimeDB.Table(Accessor = "Reminder", Scheduled = "SendReminder", ScheduledAt = "ScheduledAt")] public partial struct Reminder { [SpacetimeDB.PrimaryKey] [SpacetimeDB.AutoInc] public ulong ScheduledId; public uint UserId; public string Message; public ScheduleAt ScheduledAt; } [SpacetimeDB.Reducer] public static void SendReminder(ReducerContext ctx, Reminder reminder) { // Process the scheduled reminder } }Rust:scheduled(send_reminder)表属性
Rust 侧使用#[table(accessor = ..., scheduled(...))],并借助#[primary_key]+#[auto_inc]的scheduled_id: u64与scheduled_at: ScheduleAt列:
use spacetimedb::{reducer, table, ReducerContext, ScheduleAt, Table}; use std::time::Duration; #[table(accessor = reminder_schedule, scheduled(send_reminder))] pub struct Reminder { #[primary_key] #[auto_inc] scheduled_id: u64, user_id: u32, message: String, scheduled_at: ScheduleAt, } #[reducer] fn send_reminder(ctx: &ReducerContext, reminder: Reminder) -> Result<(), String> { // Process the scheduled reminder Ok(()) } #[reducer(init)] fn init(ctx: &ReducerContext) { ctx.db.reminder_schedule().insert(Reminder { scheduled_id: 0, user_id: 0, message: "Game tick".to_string(), scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()), }); }从宏实现可以确认调度表必需的列约束:#[table]宏会强制要求表中存在#[primary_key] #[auto_inc] scheduled_id: u64与scheduled_at: ScheduleAt(若列名不同,可用scheduled(my_reducer, at = custom_scheduled_at)指定),并做编译期类型检查,见 crates/bindings-macro/src/table.rs。
C++:SPACETIMEDB_SCHEDULE宏
C++ 服务端模块(需要满足模块版本要求)通过SPACETIMEDB_SCHEDULE宏声明调度列索引:
struct Reminder { uint64_t scheduled_id; ScheduleAt scheduled_at; std::string message; }; SPACETIMEDB_STRUCT(Reminder, scheduled_id, scheduled_at, message) SPACETIMEDB_TABLE(Reminder, reminder, Public) FIELD_PrimaryKeyAutoInc(reminder, scheduled_id) SPACETIMEDB_SCHEDULE(reminder, 1, send_reminder) // Column 1 is scheduled_at // Reducer invoked automatically by the scheduler SPACETIMEDB_REDUCER(send_reminder, ReducerContext ctx, Reminder arg) { // Invoked automatically by the scheduler // arg.message, arg.scheduled_at, arg.scheduled_id LOG_INFO("Scheduled reminder: " + arg.message); return Ok(); }底层:ScheduleAt是一个特殊求和类型
ScheduleAt并非普通的内建类型,它在 crates/lib/src/scheduler.rs 中被定义为二变体枚举:
Interval(TimeDuration):以固定时间间隔重复调度,TimeDuration支持纳秒级精度;Time(Timestamp):在某个绝对时间点一次性调度。
其代数类型是一个含Interval(time_duration)与Time(timestamp)两个变体的 sum 类型(crates/lib/src/scheduler.rs),并实现了From<TimeDuration>、From<std::time::Duration>、From<std::time::SystemTime>、From<Timestamp>等多组转换。TypeScript 侧的对应实现位于 crates/bindings-typescript/src/lib/schedule_at.ts,提供ScheduleAt.interval(micros)与ScheduleAt.time(microsSinceUnixEpoch)两个工厂函数(时间单位均为微秒)。
插入调度计划
向调度表插入带scheduled_at值的行即可安排一次动作。调度计划分两种:
- 按间隔(At intervals):固定时间间隔重复执行(例如每 5 秒一次);
- 按具体时间(At specific times):在绝对时间戳执行一次。
间隔补偿语义:间隔调度锚定在"本应执行的时间点"上。在当前实现中,如果数据库繁忙或离线过久错过了若干个间隔 tick,SpacetimeDB 会直接调度下一个未来 tick,而不会把错过的 tick 补跑,也不会让间隔因延迟执行而漂移。这一点在源码中有对应的单元测试验证:next_interval_tick_skips_missed_ticks(错过 3.5 个间隔后跳到第 4 个 tick)、next_interval_tick_is_strictly_after_now_on_boundary(恰好在边界时严格取下一个 tick),见 crates/core/src/host/scheduler.rs。
按间隔调度
间隔适合游戏 tick、心跳、周期性维护等重复任务:
:::important TypeScript:ScheduleAt的导入位置ScheduleAt从'spacetimedb'导入,不是'spacetimedb/server'。请使用:import { ScheduleAt } from 'spacetimedb';:::
import { ScheduleAt } from 'spacetimedb'; import { schema } from 'spacetimedb/server'; const spacetimedb = schema({ reminder }); // reminder table defined above export default spacetimedb; export const schedulePeriodicTasks = spacetimedb.reducer((ctx) => { // Schedule to run every 5 seconds (5,000,000 microseconds) ctx.db.reminder.insert({ scheduledId: 0n, scheduledAt: ScheduleAt.interval(5_000_000n), message: "Check for updates", }); // Schedule to run every 100 milliseconds ctx.db.reminder.insert({ scheduledId: 0n, scheduledAt: ScheduleAt.interval(100_000n), // 100ms in microseconds message: "Game tick", }); });public static partial class Module { [SpacetimeDB.Reducer] public static void SchedulePeriodicTasks(ReducerContext ctx) { // Schedule to run every 5 seconds ctx.Db.Reminder.Insert(new Reminder { ScheduledId = 0, Message = "Check for updates", ScheduledAt = new ScheduleAt.Interval(TimeSpan.FromSeconds(5)) }); // Schedule to run every 100 milliseconds ctx.Db.Reminder.Insert(new Reminder { ScheduledId = 0, Message = "Game tick", ScheduledAt = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(100)) }); } }use spacetimedb::{ScheduleAt, ReducerContext, Table}; use std::time::Duration; #[spacetimedb::reducer] fn schedule_periodic_tasks(ctx: &ReducerContext) { // Schedule to run every 5 seconds ctx.db.reminder().insert(Reminder { scheduled_id: 0, message: "Check for updates".to_string(), scheduled_at: ScheduleAt::Interval(Duration::from_secs(5).into()), }); // Schedule to run every 100 milliseconds ctx.db.reminder().insert(Reminder { scheduled_id: 0, message: "Game tick".to_string(), scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()), }); }// Schedule to run every 5 seconds ctx.db[reminder].insert(Reminder{ 0, ScheduleAt(TimeDuration::from_seconds(5)), "Check for updates" }); // Schedule to run every 100 milliseconds ctx.db[reminder].insert(Reminder{ 0, ScheduleAt(TimeDuration::from_millis(100)), "Game tick" });调度时长的上限:由于调度队列基于tokio_util::time::DelayQueue(内部最大延迟约 64^6 − 1 毫秒 ≈ 2.18 年),SpacetimeDB 在 crates/core/src/host/scheduler.rs 中定义了MAX_SCHEDULE_DELAY常量,并在Scheduler::schedule中先校验再入队;超过上限会返回ScheduleError::DelayTooLong,避免用户通过一次过长的调度让整个调度器 panic(crates/core/src/host/scheduler.rs)。
按具体时间调度
具体时间适合一次性动作,例如在特定时刻发送提醒或让内容过期:
import { ScheduleAt } from 'spacetimedb'; import { schema } from 'spacetimedb/server'; const spacetimedb = schema({ reminder }); // reminder table defined above export default spacetimedb; export const scheduleTimedTasks = spacetimedb.reducer((ctx) => { // Schedule for 10 seconds from now const tenSecondsFromNow = ctx.timestamp.microsSinceUnixEpoch + 10_000_000n; ctx.db.reminder.insert({ scheduledId: 0n, scheduledAt: ScheduleAt.time(tenSecondsFromNow), message: "Your auction has ended", }); // Schedule for a specific Unix timestamp (microseconds since epoch) const targetTime = 1735689600_000_000n; // Jan 1, 2025 00:00:00 UTC ctx.db.reminder.insert({ scheduledId: 0n, scheduledAt: ScheduleAt.time(targetTime), message: "Happy New Year!", }); });using SpacetimeDB; public static partial class Module { [SpacetimeDB.Reducer] public static void ScheduleTimedTasks(ReducerContext ctx) { // Schedule for 10 seconds from now var tenSecondsFromNow = ctx.Timestamp + new TimeDuration(10_000_000); ctx.Db.Reminder.Insert(new Reminder { ScheduledId = 0, Message = "Your auction has ended", ScheduledAt = new ScheduleAt.Time(tenSecondsFromNow) }); // Schedule for a specific time var targetTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); ctx.Db.Reminder.Insert(new Reminder { ScheduledId = 0, Message = "Happy New Year!", ScheduledAt = new ScheduleAt.Time(targetTime) }); } }use spacetimedb::{ScheduleAt, ReducerContext, Table}; use std::time::Duration; #[spacetimedb::reducer] fn schedule_timed_tasks(ctx: &ReducerContext) { // Schedule for 10 seconds from now let ten_seconds_from_now = ctx.timestamp + Duration::from_secs(10); ctx.db.reminder().insert(Reminder { scheduled_id: 0, message: "Your auction has ended".to_string(), scheduled_at: ScheduleAt::Time(ten_seconds_from_now), }); // Schedule for immediate execution (current timestamp) ctx.db.reminder().insert(Reminder { scheduled_id: 0, message: "Process now".to_string(), scheduled_at: ScheduleAt::Time(ctx.timestamp.clone()), }); }// Schedule for 10 seconds from now Timestamp tenSecondsFromNow = ctx.timestamp + TimeDuration::from_seconds(10); ctx.db[reminder].insert(Reminder{ 0, ScheduleAt(tenSecondsFromNow), "Your auction has ended" }); // Schedule for immediate execution (current timestamp) ctx.db[reminder].insert(Reminder{ 0, ScheduleAt(ctx.timestamp), "Process now" });调度的工作原理
调度执行的完整流程可以概括为四步:
- 插入一行包含
ScheduleAt值的记录; - SpacetimeDB 监控该调度表;
- 时间到达时,指定的 reducer/procedure 被自动调用,整行记录作为参数传入;
- 处理完成后,该行通常会被删除或更新(由 reducer 自行决定)。
调度器内部机制
从 crates/core/src/host/scheduler.rs 的实现看,整个调度体系包含三块关键结构:
- 系统表
st_scheduled:在 crates/datastore/src/system_tables.rs 中定义,字段包括schedule_id、table_id、reducer_name(命名空间后的名字,如子模块表"lib.library_scheduled_procedure")、schedule_name与at_column,用于登记"哪张表由哪个函数调度"。调度器启动时遍历该表,把每张调度表中的现有行全部加载进内存队列(crates/core/src/host/scheduler.rs)。 Scheduler与SchedulerActor:前者向数据库/模块宿主提供schedule接口,后者运行一个基于tokio::select!的事件循环,同时监听调度消息与DelayQueue到期事件(crates/core/src/host/scheduler.rs)。当延迟队列中的某项到期,actor 会根据函数名在模块定义中判定它是 reducer 还是 procedure,分别走call_scheduled_reducer/call_scheduled_procedure(crates/core/src/host/scheduler.rs)。- 行更新重排:如果在处理前调度行被更新,
handle_message会先从DelayQueue移除旧 key 再插入新 key,保证队列与数据库中的计划始终一致(crates/core/src/host/scheduler.rs)。
此外,调度器会记录"调度函数延迟"指标:当实际执行时间比计划时间晚超过 30ms(SCHEDULED_FUNCTION_DELAY_WARNING_THRESHOLD)时输出警告日志(crates/core/src/host/scheduler.rs)。
行的生命周期
SpacetimeDB 会把调度行整体作为参数传给被调度的 reducer 或 procedure,但一次性(one-shot)调度行的删除时机因函数类型而异:
- 被调度的 procedure:在执行前删除该行,因此在 procedure 内部
schedule_table.find(scheduled_id)会返回null,调用.update()也会失败。这一点对应源码prepare_scheduled_procedure_call中的注释——"scheduled procedures 不允许中途中止后重试,所以必须在执行前移除调度行"(crates/core/src/host/scheduler.rs)。 - 被调度的 reducer:在执行后删除该行,因此 reducer 运行期间该行在调度表中仍然可见。
- 间隔调度:永远不会被自动删除;只有一次性调度在运行后被移除。interval 行执行后由
delete_scheduled_function_row读取其ScheduleAt::Interval并计算下一个 tick 返回给调度器重新入队(crates/core/src/host/scheduler.rs)。
间隔补偿:错过即跳过
next_interval_tick_after(crates/core/src/host/scheduler.rs)实现间隔补偿:以"上一次本应执行的时刻 + 间隔"为锚点,计算自锚点至今已经过去的 tick 数,然后直接跳到下一个未来的 tick 边界,从而避免:
- 补跑堆积的错过的 tick(避免雪崩);
- 以"实际执行时刻"为起点导致间隔随时间漂移。
典型使用场景
- 提醒与通知:在特定时间点发送定时消息;
- 内容过期:自动移除或归档旧数据;
- 延迟动作:把动作排队,延迟一段时间后执行;
- 周期任务:周期性地执行维护或清理操作;
- 游戏机制:基于计时的玩法事件(建筑完成、能量恢复等)。
下一步
- 学习 Reducers,理解如何编写被调度的动作处理函数;
- 探索 Procedures,了解可执行副作用(如 HTTP 请求)的调度执行模式。
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考