From 0c3d9f414194f0a573907708b0a2e0efa02ee060 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 21:59:40 +0300 Subject: [PATCH] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-38=20SSC-37]:=20Run=20the=20daily=20reminder?= =?UTF-8?q?=20on=20the=20snapshot=20cron=20so=20production=20fits=20the=20?= =?UTF-8?q?Free=205-cron=20account=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/runbooks/cloudflare-cutover.md | 4 ++-- workers/app/src/app.test.ts | 8 ++++---- workers/app/src/background.ts | 13 +++++++------ wrangler.jsonc | 9 +++++---- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/runbooks/cloudflare-cutover.md b/docs/runbooks/cloudflare-cutover.md index c8db5ca3..f812d74b 100644 --- a/docs/runbooks/cloudflare-cutover.md +++ b/docs/runbooks/cloudflare-cutover.md @@ -20,7 +20,7 @@ returned Error 1102. Production no longer runs a Next.js server at all: | `/api/public/*`, `/api/gameplay/leaderboards/sunspots`, `/api/community-activity` | Precomputed snapshots read from Workers KV (SSC-37) | Yes, no PocketBase reads | | Anything else | `404.html`, status 404 | Yes | | Cron `*/5 * * * *` | Recomputes one public snapshot per tick (rotating) into KV | Yes (cron) | -| Cron `0 17 * * *` (production only) | Starts the daily discovery-reminder fan-out | Yes (cron) | +| Same cron, 17:00 UTC tick (production only) | Starts the daily discovery-reminder fan-out instead of a snapshot (Free allows 5 crons per account) | Yes (cron) | | Queue `starsailors-jobs` (+ `-dlq`) | Push notifications, server-side PostHog events, fan-out (SSC-39) | Yes (queue consumer) | Identity comes from Clerk's session JWT, either the `__session` cookie or an @@ -284,7 +284,7 @@ decrypts each payload and verifies its VAPID signature): | `GET /api/public/snapshots/landing-stats` (after the cron) | 200 `fresh` (503 `missing` before it) | 0 | | `POST /api/notify-my-discoveries` | 202 in 12 ms | 0 warm (1 when it had to fetch the JWKS) | | Queue: `push.user`, 3 devices (201 / 410 / 503) | 1 sent, 1 subscription deleted, 1 retried after 60 s | 6 | -| Cron `0 17 * * *` → `reminders.discoveries` → `reminders.discovery-user` | Reminder named the one unclassified discovery | 0 → 1 → 6 | +| Cron 17:00 UTC tick → `reminders.discoveries` → `reminders.discovery-user` | Reminder named the one unclassified discovery | 0 → 1 → 6 | Error rate 0% on every route. The Worker bundle is 1.76 MB (326 kB gzip), within the 3 MB Free limit. After SSC-37/39 it is 1.38 MB (256 kB gzip), because diff --git a/workers/app/src/app.test.ts b/workers/app/src/app.test.ts index 4ae55e64..373863de 100644 --- a/workers/app/src/app.test.ts +++ b/workers/app/src/app.test.ts @@ -23,7 +23,7 @@ import { resetJwksCache } from "../../api/src/jwt"; import { requestContext } from "./context"; import { createMemoryKV, type KVLike } from "@/src/server/platform"; import { resetSnapshotMemo, SNAPSHOT_BUNDLE_KEY } from "@/src/server/snapshots/store"; -import { DISCOVERY_REMINDER_CRON, SNAPSHOT_CRON, SNAPSHOT_TICK_MS } from "./background"; +import { SNAPSHOT_CRON, SNAPSHOT_TICK_MS } from "./background"; import worker, { handle, issuerFromPublishableKey, resolveAuth, type Env } from "./index"; import { auth } from "./shims/clerk-nextjs-server"; @@ -331,7 +331,7 @@ describe("public snapshots (SSC-37)", () => { describe("background jobs (SSC-39)", () => { it("starts the daily reminder fan-out from its cron", async () => { vi.spyOn(console, "log").mockImplementation(() => {}); - await worker.scheduled({ cron: DISCOVERY_REMINDER_CRON, scheduledTime: Date.parse("2026-09-25T17:00:00Z") }, env, { waitUntil: () => {} }); + await worker.scheduled({ cron: SNAPSHOT_CRON, scheduledTime: Date.parse("2026-09-25T17:00:00Z") }, env, { waitUntil: () => {} }); expect(queued.map((m) => m.body.id)).toEqual(["reminders:2026-09-25:p1"]); }); @@ -374,8 +374,8 @@ describe("background jobs (SSC-39)", () => { it("keeps wrangler.jsonc crons and queues in step with the code", () => { const config = readFileSync("wrangler.jsonc", "utf8"); - expect(config).toContain(`"crons": ["${SNAPSHOT_CRON}", "${DISCOVERY_REMINDER_CRON}"]`); - expect(config).toContain(`"crons": ["${SNAPSHOT_CRON}"]`); + expect(config).toContain(`"triggers": { "crons": ["${SNAPSHOT_CRON}"] },`); + expect(config).toContain(`"triggers": { "crons": [] }`); expect(config).toMatch(/"max_retries": 5, "dead_letter_queue": "starsailors-jobs-dlq"/); }); }); diff --git a/workers/app/src/background.ts b/workers/app/src/background.ts index 16ae48f3..9eb36e5d 100644 --- a/workers/app/src/background.ts +++ b/workers/app/src/background.ts @@ -1,7 +1,7 @@ // Cron and queue entry points of the app Worker (SSC-37, SSC-39). // -// SNAPSHOT_CRON recompute the public snapshots into Workers KV -// DISCOVERY_REMINDER_CRON start the daily reminder fan-out (production only) +// SNAPSHOT_CRON recompute one public snapshot into Workers KV per tick, and on the +// 17:00 UTC tick start the daily reminder fan-out instead // queue JOBS / JOBS_DLQ run background jobs / park dead-lettered ones // // Both run with the same 10 ms CPU cap as requests on Workers Free, so each @@ -16,15 +16,16 @@ import { refreshSnapshots, snapshotForTick } from "@/src/server/snapshots/store" // refreshed every 20 min, inside the 30 min freshness window. export const SNAPSHOT_CRON = "*/5 * * * *"; export const SNAPSHOT_TICK_MS = 5 * 60 * 1000; -export const DISCOVERY_REMINDER_CRON = "0 17 * * *"; +// Workers Free allows 5 cron triggers per account, so the reminder shares the snapshot cron. +export const DISCOVERY_REMINDER_HOUR_UTC = 17; -export async function runScheduled(cron: string, scheduledTime: number): Promise> { - if (cron === DISCOVERY_REMINDER_CRON) { +export async function runScheduled(_cron: string, scheduledTime: number): Promise> { + const at = new Date(scheduledTime); + if (at.getUTCHours() === DISCOVERY_REMINDER_HOUR_UTC && at.getUTCMinutes() < SNAPSHOT_TICK_MS / 60000) { const day = new Date(scheduledTime).toISOString().slice(0, 10); const queued = await enqueueJobs({ type: "reminders.discoveries", id: `reminders:${day}:p1`, day, page: 1 }); return { task: "discovery-reminders", day, ...queued }; } - // SNAPSHOT_CRON, and any cron added later without a handler of its own. const only = [snapshotForTick(scheduledTime, SNAPSHOT_TICK_MS)]; const report = await refreshSnapshots({ now: scheduledTime, only }); return { task: "snapshots", ...report }; diff --git a/wrangler.jsonc b/wrangler.jsonc index 0b47c6eb..0f038b82 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -55,9 +55,10 @@ { "queue": "starsailors-jobs-dlq", "max_batch_size": 10, "max_batch_timeout": 30, "max_retries": 2 } ] }, - // Must match workers/app/src/background.ts: snapshots every 5 minutes, one per tick, the - // unclassified-discovery reminder daily at 17:00 UTC. - "triggers": { "crons": ["*/5 * * * *", "0 17 * * *"] }, + // Must match workers/app/src/background.ts: snapshots every 5 minutes, one per tick; the + // 17:00 UTC tick starts the unclassified-discovery reminder instead. One cron because Workers + // Free allows 5 per account. Staging has none (its snapshots refresh only when run by hand). + "triggers": { "crons": ["*/5 * * * *"] }, // Do not set limits.cpu_ms here. The CI Cloudflare account is on Workers // Free; Wrangler then fails the whole deploy with 100328 // ("CPU limits are not supported for the Free plan"). That blocked @@ -121,7 +122,7 @@ { "queue": "starsailors-jobs-staging-dlq", "max_batch_size": 10, "max_batch_timeout": 30, "max_retries": 2 } ] }, - "triggers": { "crons": ["*/5 * * * *"] } + "triggers": { "crons": [] } } } }