# 实战：Deno Cron 服务

本教程把 [项目蓝图 3](/docs/projects) 展开为可执行步骤。Deno Deploy 的 timeline、数据库与 Cron 平台行为见 [数据库、Cron 与 Timelines](/docs/deploy/data-and-cron)，本文聚焦服务本身的写法。

## 目标与最终形态

```text
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 时间触发。

## 初始化

```bash
deno init cron-service
cd cron-service
deno add npm:postgres
```

示例用 PostgreSQL 存执行记录（`postgres` 是纯 JS 驱动，Deno 直接可用）。连接串放环境变量 `DATABASE_URL`。如果用 Deno KV 代替，思路相同——用 atomic 操作抢占唯一 key。

## 里程碑一：可测试的 job

### 业务逻辑与调度无关

```ts title="services/report.ts"
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)` 唯一约束做"认领"：

```sql title="migrations/0001_job_runs.sql"
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)
);
```

```ts title="jobs/daily-report.ts"
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

```ts title="main.ts"
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 文档](/docs/deploy/data-and-cron)）：

- 调度时间是 **UTC**；`0 3 * * *` 是 UTC 03:00，不是本地时区。
- **失败默认不重试**。需要重试必须显式给 `backoffSchedule`：数组每个元素是下一次重试前的毫秒延迟，最多 5 次重试，单次延迟上限 1 小时。
- 同一 job 不会并发执行：上一次还在跑（或重试与下一次调度重叠）时，后一次直接跳过。job 跑得比调度间隔还久时不会堆积。
- 注册必须在**模块顶层**、server 启动前完成；写在请求 handler 或条件分支里的 job 不会被 Deploy 发现。

## 测试策略

本地不要靠等待真实调度来测试——直接测函数：

```ts title="jobs/daily-report_test.ts"
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`。

## 验收清单

<Checklist id="cron-service-acceptance" items={[
  "job 是不依赖 Deno.cron 的普通函数，测试直接调用",
  "同一 execution key 重复执行无副作用（数据库唯一键兜底）",
  "失败路径记录 status/error，补跑可恢复且结果仍唯一",
  "backoffSchedule 显式配置，而非依赖默认行为",
  "cron 注册在模块顶层，schedule 按 UTC 设计",
  "生产与 preview timeline 的执行可在日志/表记录中区分",
  "deno fmt --check、deno lint、deno check、deno test 全部通过"
]} />

官方参考：[Deno Deploy Cron](https://docs.deno.com/deploy/reference/cron/)、[Timelines](https://docs.deno.com/deploy/reference/timelines/)、[postgres.js](https://github.com/porsager/postgres)。
