# "Project: Deno cron service"

This tutorial expands [blueprint 3](/en/docs/projects) into concrete steps. For Deno Deploy's timeline, database, and cron platform behavior see [Databases, cron, and timelines](/en/docs/deploy/data-and-cron); this page focuses on the service itself.

## Goal and final shape

```text
cron-service/
├── jobs/daily-report.ts   # scheduling entrypoint: claim the execution key, call the service, record the outcome
├── services/report.ts     # pure business logic, unaware of cron
├── main.ts                # Deno.cron registration (module top level)
├── migrations/            # the job_runs table
└── deno.json
```

Core discipline: **no business logic inside the `Deno.cron()` handler**. A job is a plain async function that tests call directly; cron only triggers it on UTC time.

## Setup

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

The example stores execution records in PostgreSQL (the `postgres` driver is pure JavaScript and works directly in Deno). The connection string lives in the `DATABASE_URL` environment variable. With Deno KV instead, the idea is the same — claim a unique key with an atomic operation.

## Milestone 1: a testable job

### Business logic independent of scheduling

```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` is an explicit parameter, so tests can replay any date without waiting for a real schedule.

### Idempotency: a unique execution key

The core problem: cron may retry, and a manual replay re-runs the same logical date. Register every execution in a table and "claim" it with a unique `(job_name, execution_key)` constraint:

```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 }; // already succeeded or currently running; return immediately
  }

  try {
    const report = await buildDailyReport(sql, day);
    // Business writes use day as the idempotency key too: 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;
  }
}
```

Both idempotency layers are required: `job_runs` prevents duplicate executions, and the `daily_reports` upsert guarantees that even if a retry lands after the business write but before the status update, a rerun only overwrites the same row.

Note the `do update ... where status = 'failed'` in the claim statement: with `on conflict do nothing`, a failed record would hold the unique key forever, and both platform retries and manual replays would be skipped as "already executed." Another edge is a permanent `running` row left behind when the process crashes before the status update — in production, add a reclamation rule that treats a `started_at` older than a threshold as an expired lease.

## Milestone 2: register the cron job

```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());
  },
);
```

Key points (platform details in the [Deploy docs](/en/docs/deploy/data-and-cron)):

- Schedules are **UTC**; `0 3 * * *` means 03:00 UTC, not local time.
- **Failed executions are not retried by default.** Opt in with an explicit `backoffSchedule`: each array element is the millisecond delay before the next retry, with at most 5 retries and a 1-hour ceiling per delay.
- The same job never runs concurrently: if the previous execution is still going (or a retry overlaps the next scheduled run), the later one is skipped. Long-running jobs do not pile up.
- Registration must happen at **module top level**, before the server starts; jobs declared inside request handlers or conditionals are not discovered by Deploy.

## Test strategy

Don't test by waiting for the real schedule locally — call the function directly:

```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("re-running the same day executes only once", async () => {
  // Clean the execution key first, so stale rows in the test database can't make the case pass vacuously
  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);
});
```

Add two more cases: when the business write fails midway, `job_runs` records `failed` and a replay can recover; after replaying, `daily_reports` still holds exactly one row. Use a dedicated `TEST_DATABASE_URL`, separate from the development database.

## Deployment and observability

- Deploy discovers `Deno.cron()` definitions by evaluating top-level module code at deploy time, then takes over scheduling; rolling back a revision re-registers that revision's jobs. Production and each Git branch timeline fire independently — previews run jobs too, so tag logs with `DENO_TIMELINE` and skip external side effects outside production when needed.
- Executions appear in the dashboard's Cron tab and in logs/traces (filter by `kind:cron`, `cron.name:`); the `job_runs` table answers the business questions: which day never ran, which run failed, what the replay produced.
- Each cron execution is billed as one inbound HTTP request; free organizations are limited to 10 cron jobs per revision.
- Permissions: the service needs `--allow-net` (database) and `--allow-env=DATABASE_URL` — never `-A`.

## Acceptance checklist

<Checklist id="cron-service-acceptance" items={[
  "Jobs are plain functions with no Deno.cron dependency, called directly by tests",
  "Re-running the same execution key has no side effects (database unique constraint as backstop)",
  "Failure paths record status/error; replay recovers and the result stays unique",
  "backoffSchedule is configured explicitly instead of relying on defaults",
  "Cron registration sits at module top level; schedules are designed in UTC",
  "Production and preview timeline executions are distinguishable in logs and tables",
  "deno fmt --check, deno lint, deno check, and deno test all pass"
]} />

Official references: [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).
