Step 7.3: add expired contract cleanup job
CI / check (push) Successful in 1m35s
Details
CI / check (push) Successful in 1m35s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
11f3bb1897
commit
e58e99c156
|
|
@ -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.
|
- **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 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).
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@ import type { Clock } from "../clock";
|
||||||
import type { Db } from "../db/client";
|
import type { Db } from "../db/client";
|
||||||
import { withPlayerLock } from "../game/lock";
|
import { withPlayerLock } from "../game/lock";
|
||||||
import { resolveArrivedDispatches } from "../game/resolve";
|
import { resolveArrivedDispatches } from "../game/resolve";
|
||||||
|
import { cleanupExpiredContracts } from "./cleanup";
|
||||||
import type { JobDefinition } from "./queue";
|
import type { JobDefinition } from "./queue";
|
||||||
|
|
||||||
export const RESOLVE_ARRIVALS_JOB = "resolve-arrivals";
|
export const RESOLVE_ARRIVALS_JOB = "resolve-arrivals";
|
||||||
|
export const CLEANUP_CONTRACTS_JOB = "cleanup-expired-contracts";
|
||||||
|
|
||||||
export interface JobContext {
|
export interface JobContext {
|
||||||
db: Db;
|
db: Db;
|
||||||
|
|
@ -26,5 +28,15 @@ export async function resolveArrivalsJob(ctx: JobContext, data: Record<string, u
|
||||||
|
|
||||||
/** Every background job the app runs. */
|
/** Every background job the app runs. */
|
||||||
export function appJobs(ctx: JobContext): JobDefinition[] {
|
export function appJobs(ctx: JobContext): JobDefinition[] {
|
||||||
return [{ name: RESOLVE_ARRIVALS_JOB, handler: (data) => 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());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 * * * *");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -393,3 +393,14 @@ Handlers throw `ApiError(status, code, message)` for expected failures. Unknown
|
||||||
| `names.ts` | Seeded ship and pilot names |
|
| `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`.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.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.
|
- [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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue