Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions docs/runbooks/cloudflare-cutover.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
| 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) |
| Same cron, 17:00 UTC tick (production only; staging sets `WORKER_ENV=staging` and skips it, since it shares production users) | 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
Expand Down Expand Up @@ -133,7 +133,7 @@ message (for example `POST /api/notify-my-discoveries` → 202).
- A batch holds at most 4 jobs, and each user gets at most 5 devices per
job, which keeps a batch under 50 subrequests.
- Queues Free allows 10,000 operations/day, about 3 per message.
- Cron triggers: 2 in production and 1 in staging, out of 5 per account.
- Cron triggers: 1 in production and 1 in staging (snapshots only), out of 5 per account.
- **Security.** `/api/notify-my-discoveries` used to push to any `userId` in
the body without authentication. `/api/send-test-notification` and
`/api/auto-notify-discoveries` were open to anyone. They now require a
Expand Down Expand Up @@ -199,10 +199,14 @@ Steps:
4. Merge to `main`, or run "Deploy to Cloudflare Workers" manually. Note the
previous version id first: `npx wrangler deployments list`.
5. Smoke-test production and run the budget workflow with `target: production`.
6. Delete the standalone SSC-35 Worker, now served by the app Worker, if it
was ever deployed: `npx wrangler delete --name starsailors-api` and
`--name starsailors-api-staging`. Its `/api/v1/*` routes would otherwise
keep shadowing the app Worker.
6. Retire the standalone SSC-35 API. Its zone routes win over the app Worker, so
delete them first (`GET /zones/{id}/workers/routes` must show none for
`/api/v1/*`), then `npx wrangler delete --name starsailors-api`. Done on
2026-09-30 for production and staging (`starsailors-api`,
`starsailors-api-staging`); check for stale routes on any future cutover.
7. Workers Free allows 5 cron triggers per account, shared with every Worker on
it. Production and staging use one `*/5 * * * *` each. If a deploy fails
at `/schedules`, count them with `GET /workers/scripts/{name}/schedules`.

## Smoke test

Expand Down
9 changes: 8 additions & 1 deletion workers/app/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,12 @@ describe("background jobs (SSC-39)", () => {
expect(queued.map((m) => m.body.id)).toEqual(["reminders:2026-09-25:p1"]);
});

it("does not start the reminder fan-out on staging", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
await worker.scheduled({ cron: SNAPSHOT_CRON, scheduledTime: Date.parse("2026-09-25T17:00:00Z") }, { ...env, WORKER_ENV: "staging" }, { waitUntil: () => {} });
expect(queued).toEqual([]);
});

it("queues the signed-in user's notification and answers 202 at once", async () => {
const res = await call("/api/notify-my-discoveries", {
method: "POST",
Expand Down Expand Up @@ -375,7 +381,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(`"triggers": { "crons": ["${SNAPSHOT_CRON}"] },`);
expect(config).toContain(`"triggers": { "crons": [] }`);
expect(config.match(new RegExp(`"triggers": \\{ "crons": \\["${SNAPSHOT_CRON.replace(/\*/g, "\\*")}"\\] \\}`, "g"))).toHaveLength(2);
expect(config).toContain(`"WORKER_ENV": "staging"`);
expect(config).toMatch(/"max_retries": 5, "dead_letter_queue": "starsailors-jobs-dlq"/);
});
});
Expand Down
5 changes: 3 additions & 2 deletions workers/app/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ export const SNAPSHOT_TICK_MS = 5 * 60 * 1000;
// 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<Record<string, unknown>> {
// `remind` is false on staging: it shares production's PocketBase and users, so only snapshots run there.
export async function runScheduled(_cron: string, scheduledTime: number, remind = true): Promise<Record<string, unknown>> {
const at = new Date(scheduledTime);
if (at.getUTCHours() === DISCOVERY_REMINDER_HOUR_UTC && at.getUTCMinutes() < SNAPSHOT_TICK_MS / 60000) {
if (remind && 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 };
Expand Down
4 changes: 3 additions & 1 deletion workers/app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type Env = {
POCKETBASE_ADMIN_EMAIL: string;
POCKETBASE_ADMIN_PASSWORD: string;
posthog_region?: string;
/** Set to "staging" on the staging Worker (no reminder fan-out from its cron). */
WORKER_ENV?: string;
/** Workers KV: published public snapshots, job receipts and parked jobs. */
PUBLIC_DATA?: KVLike;
/** Cloudflare Queues producer for background jobs. */
Expand Down Expand Up @@ -282,7 +284,7 @@ async function background(env: Env, ctx: ExecutionContextLike, label: string, fn
export default {
fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => handle(request, env, { ctx }),
scheduled: (controller: { cron: string; scheduledTime: number }, env: Env, ctx: ExecutionContextLike) =>
background(env, ctx, `cron ${controller.cron}`, () => runScheduled(controller.cron, controller.scheduledTime)),
background(env, ctx, `cron ${controller.cron}`, () => runScheduled(controller.cron, controller.scheduledTime, env.WORKER_ENV !== "staging")),
queue: (batch: QueueBatchLike, env: Env, ctx: ExecutionContextLike) =>
background(env, ctx, `queue ${batch.queue}`, () => runQueueBatch(batch)),
};
9 changes: 5 additions & 4 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
},
// 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).
// Free allows 5 per account. Staging has its own `*/5` (2 of 5 used), snapshots only.
"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
Expand Down Expand Up @@ -103,6 +103,7 @@
"@clerk/nextjs/webhooks": "@clerk/backend/webhooks"
},
"vars": {
"WORKER_ENV": "staging",
"NEXT_PUBLIC_POSTHOG_KEY": "phc_65umDftbbTkrm1V6azue6OeU4u5c8iJcaHm4JtJ95di",
"NEXT_PUBLIC_POSTHOG_HOST": "https://us.posthog.com",
"posthog_api_key": "phc_65umDftbbTkrm1V6azue6OeU4u5c8iJcaHm4JtJ95di",
Expand All @@ -112,8 +113,8 @@
"observability": {
"enabled": true
},
// Separate KV and queues from production. Snapshots only: no reminder
// cron, since staging shares production's PocketBase and users.
// Separate KV and queues from production. Snapshots only: WORKER_ENV
// stops the 17:00 UTC reminder, since staging shares production's PocketBase and users.
"kv_namespaces": [{ "binding": "PUBLIC_DATA", "id": "PUBLIC_DATA_KV_ID_STAGING" }],
"queues": {
"producers": [{ "binding": "JOBS", "queue": "starsailors-jobs-staging" }],
Expand All @@ -122,7 +123,7 @@
{ "queue": "starsailors-jobs-staging-dlq", "max_batch_size": 10, "max_batch_timeout": 30, "max_retries": 2 }
]
},
"triggers": { "crons": [] }
"triggers": { "crons": ["*/5 * * * *"] }
}
}
}
Loading