diff --git a/DECISIONS.md b/DECISIONS.md index f84a980..97df18d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -354,3 +354,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Jobs are sent after the dispatch transaction commits.** A failed send is logged and the dispatch still succeeds, because lazy resolution on read is the safety net. - **The app clock is `base clock + offset`** (`createOffsetClock`). The offset is always 0 except through `POST /api/test/clock-offset` (`{ offsetMs }`), which is registered only when `NODE_ENV` is not `production` and is not in the route catalog. Job timers use the base (real) clock: `startAfter = arrivalAt - offset`. - **The job's handler reads time from the same clock as requests,** so tests run handlers directly with a controlled clock (decision 0.5.18). + +## Step 7.3 + +- **Retention window: 24 board periods** (one day at the default one-hour period), as the server constant `BOARD_RETENTION_PERIODS`. It is operational housekeeping, not game tuning, so it is not in GameConfig. +- **Cleanup deletes boards and accepted-contract rows whose period is older than the window**, in one transaction. Dispatches keep their own contract snapshot, so in-flight and resolved dispatches are unaffected. A contract from a deleted board can no longer be found, so dispatching it returns `unknown-contract` instead of `contract-expired`. +- **The job runs hourly on the hour (UTC cron `0 * * * *`)** through pg-boss's scheduler, only in processes with workers enabled. diff --git a/apps/server/src/jobs/cleanup.ts b/apps/server/src/jobs/cleanup.ts new file mode 100644 index 0000000..a85658e --- /dev/null +++ b/apps/server/src/jobs/cleanup.ts @@ -0,0 +1,34 @@ +import { content } from "@deadhead/content"; +import { boardPeriodIndex, type RealTime } from "@deadhead/sim"; +import { lt } from "drizzle-orm"; +import type { Db } from "../db/client"; +import { acceptedContracts, contractBoards } from "../db/schema"; + +/** + * How many past board periods to keep. Older boards are never shown, and their + * contracts are expired; dispatches keep their own contract snapshot. + */ +export const BOARD_RETENTION_PERIODS = 24; + +/** + * Deletes stored contract boards and accepted-contract records from periods + * older than the retention window (Step 7.3). Returns how many rows went. + */ +export async function cleanupExpiredContracts( + db: Db, + now: RealTime, + retentionPeriods = BOARD_RETENTION_PERIODS, +): Promise<{ boards: number; acceptedContracts: number }> { + const cutoff = boardPeriodIndex(now, content.config) - retentionPeriods; + return db.transaction(async (tx) => { + const boards = await tx + .delete(contractBoards) + .where(lt(contractBoards.periodIndex, cutoff)) + .returning({ id: contractBoards.id }); + const accepted = await tx + .delete(acceptedContracts) + .where(lt(acceptedContracts.periodIndex, cutoff)) + .returning({ id: acceptedContracts.contractId }); + return { boards: boards.length, acceptedContracts: accepted.length }; + }); +} diff --git a/apps/server/src/jobs/definitions.ts b/apps/server/src/jobs/definitions.ts index 6e240ea..f2e6afb 100644 --- a/apps/server/src/jobs/definitions.ts +++ b/apps/server/src/jobs/definitions.ts @@ -2,9 +2,11 @@ import type { Clock } from "../clock"; import type { Db } from "../db/client"; import { withPlayerLock } from "../game/lock"; import { resolveArrivedDispatches } from "../game/resolve"; +import { cleanupExpiredContracts } from "./cleanup"; import type { JobDefinition } from "./queue"; export const RESOLVE_ARRIVALS_JOB = "resolve-arrivals"; +export const CLEANUP_CONTRACTS_JOB = "cleanup-expired-contracts"; export interface JobContext { db: Db; @@ -26,5 +28,15 @@ export async function resolveArrivalsJob(ctx: JobContext, data: Record resolveArrivalsJob(ctx, data) }]; + return [ + { name: RESOLVE_ARRIVALS_JOB, handler: (data) => resolveArrivalsJob(ctx, data) }, + { + name: CLEANUP_CONTRACTS_JOB, + // Hourly, on the hour (UTC). + cron: "0 * * * *", + handler: async () => { + await cleanupExpiredContracts(ctx.db, ctx.clock()); + }, + }, + ]; } diff --git a/apps/server/test/cleanup.test.ts b/apps/server/test/cleanup.test.ts new file mode 100644 index 0000000..1f213fc --- /dev/null +++ b/apps/server/test/cleanup.test.ts @@ -0,0 +1,80 @@ +import { content } from "@deadhead/content"; +import { boardPeriodIndex, boardPeriodMs } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { acceptedContracts, contractBoards } from "../src/db/schema"; +import { BOARD_RETENTION_PERIODS, cleanupExpiredContracts } from "../src/jobs/cleanup"; +import { appJobs, CLEANUP_CONTRACTS_JOB } from "../src/jobs/definitions"; +import { makeOwner, TEST_NOW, useTestDatabase } from "./db"; +import { insertDispatch, putBoard, signUp, testContract, useTestApp } from "./game"; + +const database = useTestDatabase(); +const testApp = useTestApp(database); + +describe("expired contract cleanup", () => { + it("deletes old boards and accepted contracts and keeps current ones", async () => { + const { db } = database(); + const guest = await signUp(testApp().app); + const shipId = await makeOwner(db, guest.playerId, guest.avatarId); + const period = boardPeriodMs(content.config); + const current = boardPeriodIndex(TEST_NOW, content.config); + const old = current - BOARD_RETENTION_PERIODS - 1; + + // A board from long ago and one from the current period. + await putBoard(db, guest.playerId, [testContract()], TEST_NOW - (current - old) * period); + await putBoard(db, guest.playerId, [testContract()], TEST_NOW); + + // An accepted contract from each period, both pointing at real dispatches. + const record = await insertDispatch(db, { + playerId: guest.playerId, + shipId, + pilotId: guest.avatarId, + departAt: TEST_NOW, + }); + await db.insert(acceptedContracts).values([ + { + playerId: guest.playerId, + contractId: "old", + periodIndex: old, + dispatchId: record.id, + acceptedAt: TEST_NOW, + }, + { + playerId: guest.playerId, + contractId: "new", + periodIndex: current, + dispatchId: record.id, + acceptedAt: TEST_NOW, + }, + ]); + + expect(await cleanupExpiredContracts(db, TEST_NOW)).toEqual({ + boards: 1, + acceptedContracts: 1, + }); + expect((await db.select().from(contractBoards)).map((b) => b.periodIndex)).toEqual([current]); + expect((await db.select().from(acceptedContracts)).map((a) => a.contractId)).toEqual(["new"]); + }); + + it("keeps boards inside the retention window", async () => { + const { db } = database(); + const guest = await signUp(testApp().app); + const period = boardPeriodMs(content.config); + await putBoard( + db, + guest.playerId, + [testContract()], + TEST_NOW - BOARD_RETENTION_PERIODS * period, + ); + expect(await cleanupExpiredContracts(db, TEST_NOW)).toEqual({ + boards: 0, + acceptedContracts: 0, + }); + }); + + it("runs as an hourly job", () => { + const job = appJobs({ db: database().db, clock: () => TEST_NOW }).find( + (j) => j.name === CLEANUP_CONTRACTS_JOB, + ); + expect(job?.cron).toBe("0 * * * *"); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index a04639e..ba056ac 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -393,3 +393,14 @@ Handlers throw `ApiError(status, code, message)` for expected failures. Unknown | `names.ts` | Seeded ship and pilot names | Routes (`apps/server/src/routes`) wrap every authenticated action in `withPlayer`: lock, resolve due arrivals, run the action, then `maybeEndTutorial`. Game-rule failures are `409` with their error code; unknown things are `404`. + +## Background jobs (`apps/server/src/jobs`) + +pg-boss (`queue.ts`) in the app's Postgres database (schema `pgboss`), behind a `JobQueue` interface; tests use a recording fake and call handlers directly. Queues are always created; workers and cron schedules run only when `WORKERS_ENABLED=true`. + +| Job | Trigger | Does | +|---|---|---| +| `resolve-arrivals` | Sent after each dispatch commits, `startAfter` = arrival (on the real clock) | `resolveArrivedDispatches` under the player lock; no-op if a request already resolved it | +| `cleanup-expired-contracts` | Cron `0 * * * *` | Deletes boards and accepted contracts older than 24 periods | + +The app clock is `base clock + offset`; the offset is set only by the test-only `POST /api/test/clock-offset`, which is not registered in production. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 44c54e6..1da7880 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -73,4 +73,4 @@ - [x] **Step 7.1 — pg-boss setup.** `src/jobs/queue.ts`: `JobQueue` interface, pg-boss implementation (queues always created; workers and cron schedules only when `WORKERS_ENABLED=true`), and a recording fake for tests. The app starts the queue on ready and stops it on close. Test runs a no-op job to completion with real pg-boss. - [x] **Step 7.2 — Arrival resolution job.** Every dispatch (contract, tutorial, reposition) schedules a `resolve-arrivals` job at its arrival (`src/jobs/definitions.ts`), which runs the same locked resolution as lazy reads and is a no-op if already resolved. Clock is now base clock + offset; test-only `POST /api/test/clock-offset` (not registered in production) moves it. -- [ ] Step 7.3 — Expired contract cleanup +- [x] **Step 7.3 — Expired contract cleanup.** Hourly `cleanup-expired-contracts` job (`src/jobs/cleanup.ts`) deletes stored boards and accepted-contract records more than 24 board periods old; current ones stay.