实战:Deno Cron 服务
把 job 写成可测试的普通函数,用 Deno.cron 触发,以唯一 execution key 保证幂等
本教程把 项目蓝图 3 展开为可执行步骤。Deno Deploy 的 timeline、数据库与 Cron 平台行为见 数据库、Cron 与 Timelines,本文聚焦服务本身的写法。
目标与最终形态
cron-service/
├── jobs/daily-report.ts # 调度入口:认领 execution key、调用 service、记录结果
├── services/report.ts # 纯业务逻辑,不感知 cron
├── main.ts # Deno.cron 注册(模块顶层)
├── migrations/ # job_runs 表
└── deno.json
核心纪律:Deno.cron() 的 handler 里不放业务逻辑。job 是普通 async 函数,测试直接调用它;cron 只负责按 UTC 时间触发。
初始化
deno init cron-service
cd cron-service
deno add npm:postgres
示例用 PostgreSQL 存执行记录(postgres 是纯 JS 驱动,Deno 直接可用)。连接串放环境变量 DATABASE_URL。如果用 Deno KV 代替,思路相同——用 atomic 操作抢占唯一 key。
里程碑一:可测试的 job
业务逻辑与调度无关
import type { Sql } from "postgres";
export async function buildDailyReport(sql: Sql, day: string) {
const rows = await sql`
select count(*)::int as orders, coalesce(sum(total), 0)::numeric as revenue
from orders
where created_at >= ${day}::date
and created_at < (${day}::date + interval '1 day')`;
return { day, orders: rows[0].orders, revenue: rows[0].revenue };
}
入参显式传入 day,测试时可以重放任意一天,不必等待真实调度。
幂等:唯一 execution key
关键问题:cron 可能重试,补跑也会再次执行同一逻辑日期。用一张执行登记表,以 (job_name, execution_key) 唯一约束做"认领":
create table job_runs (
id serial primary key,
job_name text not null,
execution_key text not null,
status text not null default 'running',
started_at timestamptz not null default now(),
finished_at timestamptz,
error text,
unique (job_name, execution_key)
);
import type { Sql } from "postgres";
import { buildDailyReport } from "../services/report.ts";
export async function runDailyReport(sql: Sql, day: string) {
const claimed = await sql`
insert into job_runs (job_name, execution_key)
values ('daily-report', ${day})
on conflict (job_name, execution_key) do update
set status = 'running', started_at = now(),
finished_at = null, error = null
where job_runs.status = 'failed'
returning id`;
if (claimed.length === 0) {
return { skipped: true, day }; // 已成功或正在执行,直接返回
}
try {
const report = await buildDailyReport(sql, day);
// 业务写入同样以 day 为幂等键:delete + insert 或 upsert
await sql`
insert into daily_reports (day, orders, revenue)
values (${day}, ${report.orders}, ${report.revenue})
on conflict (day) do update
set orders = excluded.orders, revenue = excluded.revenue`;
await sql`
update job_runs set status = 'ok', finished_at = now()
where id = ${claimed[0].id}`;
return report;
} catch (err) {
await sql`
update job_runs set status = 'failed', finished_at = now(),
error = ${String(err)}
where id = ${claimed[0].id}`;
throw err;
}
}
两层幂等缺一不可:job_runs 防止重复执行,daily_reports 的 upsert 保证即使重试发生在业务写入之后、状态更新之前,重跑也只覆盖同一行。
注意认领语句的 do update ... where status = 'failed':用 on conflict do nothing 会让失败记录永远占住唯一键,平台重试和人工补跑都会被当成“已执行”跳过。另一个边界是进程在状态更新前崩溃留下的永久 running——生产中再加一条“started_at 超过阈值视为租约过期”的回收条件。
里程碑二:注册 cron
import postgres from "postgres";
import { runDailyReport } from "./jobs/daily-report.ts";
const sql = postgres(Deno.env.get("DATABASE_URL")!);
function utcYesterday(): string {
const d = new Date(Date.now() - 86_400_000);
return d.toISOString().slice(0, 10);
}
Deno.cron(
"daily-report",
"0 3 * * *",
{ backoffSchedule: [60_000, 300_000, 900_000] },
async () => {
console.log(JSON.stringify({ job: "daily-report", day: utcYesterday() }));
await runDailyReport(sql, utcYesterday());
},
);
要点(平台行为细节见 deploy 文档):
- 调度时间是 UTC;
0 3 * * *是 UTC 03:00,不是本地时区。 - 失败默认不重试。需要重试必须显式给
backoffSchedule:数组每个元素是下一次重试前的毫秒延迟,最多 5 次重试,单次延迟上限 1 小时。 - 同一 job 不会并发执行:上一次还在跑(或重试与下一次调度重叠)时,后一次直接跳过。job 跑得比调度间隔还久时不会堆积。
- 注册必须在模块顶层、server 启动前完成;写在请求 handler 或条件分支里的 job 不会被 Deploy 发现。
测试策略
本地不要靠等待真实调度来测试——直接测函数:
import { assertEquals } from "jsr:@std/assert";
import postgres from "postgres";
import { runDailyReport } from "./daily-report.ts";
const sql = postgres(Deno.env.get("TEST_DATABASE_URL")!);
Deno.test("重复执行同一天只跑一次", async () => {
// 先清理 execution key,避免测试库里的旧数据让用例假通过
await sql`delete from job_runs where job_name = 'daily-report' and execution_key = '2026-08-01'`;
await sql`delete from daily_reports where day = '2026-08-01'`;
const first = await runDailyReport(sql, "2026-08-01");
const second = await runDailyReport(sql, "2026-08-01");
assertEquals((first as { skipped?: boolean }).skipped, undefined);
assertEquals((second as { skipped?: boolean }).skipped, true);
const rows = await sql`
select count(*)::int as n from daily_reports where day = '2026-08-01'`;
assertEquals(rows[0].n, 1);
});
再补两类用例:业务写入中途失败时 job_runs 记录 failed 并可补跑;补跑后 daily_reports 仍只有一行。测试库用独立 TEST_DATABASE_URL,和开发库分开。
部署与观测
- Deploy 在发布时评估顶层模块代码、发现
Deno.cron()定义并接管调度;回滚 revision 会重新注册该 revision 的 cron。生产与各 Git 分支 timeline 各自独立触发,注意 preview 环境也会跑——用DENO_TIMELINE区分日志维度,必要时在非生产 timeline 跳过外部副作用。 - 执行记录出现在 dashboard 的 Cron 标签页与日志/traces 中(可按
kind:cron、cron.name:过滤);job_runs表则回答业务层问题:哪一天没跑、哪次失败、补跑结果。 - 每次 cron 执行按一次入站 HTTP 请求计费;免费组织每个 revision 最多 10 个 cron job。
- 权限:cron 服务需要
--allow-net(数据库)与--allow-env=DATABASE_URL,不要给-A。
验收清单
- job 是不依赖 Deno.cron 的普通函数,测试直接调用
- 同一 execution key 重复执行无副作用(数据库唯一键兜底)
- 失败路径记录 status/error,补跑可恢复且结果仍唯一
- backoffSchedule 显式配置,而非依赖默认行为
- cron 注册在模块顶层,schedule 按 UTC 设计
- 生产与 preview timeline 的执行可在日志/表记录中区分
- deno fmt --check、deno lint、deno check、deno test 全部通过