文档实战项目

实战: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 文档):

  • 调度时间是 UTC0 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:croncron.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 全部通过

官方参考:Deno Deploy CronTimelinespostgres.js

输入关键词搜索全部文档。