@chaos-overlords/worker 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,21 @@ The D1 migration lineages ship in `@chaos-overlords/storage` and `@chaos-overlor
23
23
  `ABANDONED_RETENTION_DAYS`, `BUG_REPORT_RETENTION_DAYS` and `BUG_REPORT_DAILY_STATE_MB` are vars. The
24
24
  `scheduled` handler expects a cron trigger; five minutes is the interval its sweeper is written for.
25
25
 
26
+ **A deployment without a cron trigger has no safety net.** The sweeper is what seals a turn whose
27
+ Durable Object alarm never fired, finishes a seal whose isolate died halfway through, re-runs the
28
+ verdict of a match left paused by an interrupted one, and collects retention. Nothing else does any
29
+ of it, and nothing fails loudly when it is missing: matches simply stop advancing for the players in
30
+ them. `wrangler.dev.toml` deliberately carries no `[triggers]` block, because it is not a
31
+ deployment — so the first thing to add to a real one is:
32
+
33
+ ```toml
34
+ [triggers]
35
+ crons = ["*/5 * * * *"]
36
+ ```
37
+
38
+ The Worker logs `cron trigger has not fired` on a request once it has been up for an hour without
39
+ one, which is how a deployment that forgot finds out from its own logs rather than from a player.
40
+
26
41
  ## Install
27
42
 
28
43
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaos-overlords/worker",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Cloudflare Workers runtime facade: the shared Hono app over D1, with one Durable Object per match for SSE fan-out and turn-deadline alarms.",
5
5
  "keywords": [
6
6
  "chaos-overlords",
@@ -47,22 +47,22 @@
47
47
  "provenance": true
48
48
  },
49
49
  "dependencies": {
50
- "@chaos-overlords/bug-reports": "0.8.1",
51
- "@chaos-overlords/contracts": "0.8.1",
52
- "@chaos-overlords/kernel": "0.8.1",
53
- "@chaos-overlords/server": "0.8.1",
54
- "@chaos-overlords/storage": "0.8.1",
50
+ "@chaos-overlords/bug-reports": "0.9.0",
51
+ "@chaos-overlords/contracts": "0.9.0",
52
+ "@chaos-overlords/kernel": "0.9.0",
53
+ "@chaos-overlords/server": "0.9.0",
54
+ "@chaos-overlords/storage": "0.9.0",
55
55
  "drizzle-orm": "^0.45.2",
56
- "hono": "^4.13.7"
56
+ "hono": "^4.13.8"
57
57
  },
58
58
  "devDependencies": {
59
- "@chaos-overlords/client": "0.8.1",
60
- "@chaos-overlords/conformance": "0.8.1",
59
+ "@chaos-overlords/client": "0.9.0",
60
+ "@chaos-overlords/conformance": "0.9.0",
61
61
  "@cloudflare/vitest-pool-workers": "^0.22.0",
62
- "@cloudflare/workers-types": "^5.20260910.1",
63
- "typescript": "^5.9.3",
62
+ "@cloudflare/workers-types": "^5.20260920.1",
63
+ "typescript": "^7.0.2",
64
64
  "vitest": "^4.1.11",
65
- "wrangler": "^4.131.0"
65
+ "wrangler": "^4.135.0"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "tsc -p tsconfig.json --noEmit",
package/src/MatchHub.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { isDomainError } from '@chaos-overlords/kernel'
1
+ import {
2
+ isDomainError,
3
+ type MultiplayerStorage,
4
+ type PersistedEvent,
5
+ } from '@chaos-overlords/kernel'
2
6
  import { DEFAULT_SERVER_CONFIG, LocalEventHub } from '@chaos-overlords/server'
3
7
  import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
4
8
  import type { DurableObjectState } from '@cloudflare/workers-types'
@@ -20,30 +24,30 @@ const DEADLINE_KEY = 'deadline'
20
24
  */
21
25
  export class MatchHub {
22
26
  private readonly hub: LocalEventHub
27
+ private readonly repositories: MultiplayerStorage
23
28
 
24
29
  constructor(
25
30
  private readonly state: DurableObjectState,
26
31
  private readonly env: Env,
27
32
  ) {
28
- const storage = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
29
- this.hub = new LocalEventHub(storage.events, DEFAULT_SERVER_CONFIG.sseHeartbeatMs)
33
+ this.repositories = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
34
+ this.hub = new LocalEventHub(this.repositories.events, DEFAULT_SERVER_CONFIG.sseHeartbeatMs)
30
35
  }
31
36
 
32
37
  async fetch(request: Request): Promise<Response> {
33
38
  const url = new URL(request.url)
34
39
  switch (url.pathname) {
35
40
  case HUB_PATHS.notify: {
36
- const { matchId } = (await request.json()) as { matchId: string }
37
- this.hub.wake(matchId)
41
+ const event = (await request.json()) as PersistedEvent & { matchId?: string }
42
+ // The event body when the caller sent one; a bare match id is still accepted, so an isolate
43
+ // running an older build cannot silence this object's streams.
44
+ if (typeof event.seq === 'number') await this.hub.notify(event)
45
+ else if (event.matchId) this.hub.wake(event.matchId)
38
46
  return new Response(null, { status: 204 })
39
47
  }
40
48
  case HUB_PATHS.schedule: {
41
49
  const body = (await request.json()) as PendingDeadline & { dueAt: string }
42
- await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, {
43
- matchId: body.matchId,
44
- turn: body.turn,
45
- })
46
- await this.state.storage.setAlarm(new Date(body.dueAt).getTime())
50
+ await this.arm(body.matchId, body.turn, new Date(body.dueAt).getTime())
47
51
  return new Response(null, { status: 204 })
48
52
  }
49
53
  case HUB_PATHS.subscribe: {
@@ -78,16 +82,10 @@ export class MatchHub {
78
82
  if (!pending) return
79
83
  const kernel = buildKernel(this.env, {
80
84
  // Already inside the hub: wake local streams directly instead of calling ourselves.
81
- notifier: { notify: async (event) => this.hub.wake(event.matchId) },
85
+ notifier: { notify: async (event) => this.hub.notify(event) },
82
86
  streams: this.hub,
83
87
  scheduler: {
84
- schedule: async (input) => {
85
- await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, {
86
- matchId: input.matchId,
87
- turn: input.turn,
88
- })
89
- await this.state.storage.setAlarm(input.dueAt.getTime())
90
- },
88
+ schedule: async (input) => this.arm(input.matchId, input.turn, input.dueAt.getTime()),
91
89
  },
92
90
  })
93
91
  const sealed = await kernel.turns.trySeal(pending.matchId, pending.turn, 'deadline')
@@ -95,6 +93,12 @@ export class MatchHub {
95
93
  await this.forgetSpentDeadline(pending)
96
94
  }
97
95
 
96
+ /** Record which turn the alarm is for, then set the alarm. One deadline is pending at a time. */
97
+ private async arm(matchId: string, turn: number, dueAtMs: number): Promise<void> {
98
+ await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, { matchId, turn })
99
+ await this.state.storage.setAlarm(dueAtMs)
100
+ }
101
+
98
102
  /**
99
103
  * Drop the pending deadline once nothing is left to fire for. A seal on readiness, a finished
100
104
  * match and a match retention deleted all leave the alarm armed for a turn that is no longer
@@ -105,10 +109,9 @@ export class MatchHub {
105
109
  private async forgetSpentDeadline(fired: PendingDeadline): Promise<void> {
106
110
  const current = await this.state.storage.get<PendingDeadline>(DEADLINE_KEY)
107
111
  if (!current || current.matchId !== fired.matchId || current.turn !== fired.turn) return
108
- const storage = createSqliteStorage(drizzle(this.env.DB, { schema: sqliteSchema }))
109
112
  const [match, turn] = await Promise.all([
110
- storage.matches.get(fired.matchId),
111
- storage.turns.get(fired.matchId, fired.turn),
113
+ this.repositories.matches.get(fired.matchId),
114
+ this.repositories.turns.get(fired.matchId, fired.turn),
112
115
  ])
113
116
  const stillDue =
114
117
  match?.status === 'running' && turn?.status === 'open' && turn.deadlineAt !== null
package/src/index.ts CHANGED
@@ -87,6 +87,10 @@ export function buildContainer(env: Env): ServerContainer {
87
87
  env.BUG_REPORT_RATE_LIMIT_PER_MINUTE,
88
88
  DEFAULT_RATE_LIMITS.bugReportPerMinute,
89
89
  ),
90
+ bugReportState: new RateLimiter(clock, {
91
+ limit: DEFAULT_RATE_LIMITS.bugReportStatePerDay,
92
+ windowMs: 24 * 60 * 60 * 1000,
93
+ }),
90
94
  },
91
95
  // Listing is on unless a deployment turns it off: an unset var means the Browse screen works,
92
96
  // rather than every client being told the server lists nothing.
@@ -98,8 +102,37 @@ export function buildContainer(env: Env): ServerContainer {
98
102
  }
99
103
  }
100
104
 
105
+ /**
106
+ * Whether this isolate has ever seen the cron fire, and when it started.
107
+ *
108
+ * A deployment with no cron trigger loses every safety net the `scheduled` handler is: the seal of
109
+ * a turn whose Durable Object alarm never fired, the repair of a seal an isolate died in the
110
+ * middle of, the re-run of a verdict cut short, and all of retention. None of that fails loudly —
111
+ * matches just stop advancing for the people in them — so it is worth one log line. Per isolate,
112
+ * which means a busy Worker says it a few times and then never again; that is the right volume for
113
+ * something whose remedy is four lines of `wrangler.toml`.
114
+ */
115
+ let cronSeen = false
116
+ let cronWatchStartedAt = 0
117
+ const CRON_GRACE_MS = 60 * 60 * 1000
118
+
119
+ function warnIfCronIsMissing(): void {
120
+ if (cronSeen) return
121
+ const now = Date.now()
122
+ if (cronWatchStartedAt === 0) {
123
+ cronWatchStartedAt = now
124
+ return
125
+ }
126
+ if (now - cronWatchStartedAt < CRON_GRACE_MS) return
127
+ cronWatchStartedAt = now
128
+ workerLogger.warn('cron trigger has not fired', {
129
+ hint: 'add [triggers] crons = ["*/5 * * * *"] to wrangler.toml; without it nothing seals a missed deadline, finishes an interrupted seal or collects retention',
130
+ })
131
+ }
132
+
101
133
  export default {
102
134
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
135
+ warnIfCronIsMissing()
103
136
  return containerFor(env).app.fetch(request, env, ctx)
104
137
  },
105
138
  /** The cron safety net: expired deadlines, interrupted seals, and retention. */
@@ -108,21 +141,33 @@ export default {
108
141
  env: Env,
109
142
  ctx: ExecutionContext,
110
143
  ): Promise<void> {
144
+ cronSeen = true
111
145
  const { container } = containerFor(env)
112
146
  const { kernel } = container
147
+ // Each step in its own `try`, as the Node sweeper already does. One shared `catch` meant a
148
+ // throw inside the turn sweep also stopped both retention sweeps, every time the cron ran.
149
+ const step = async (name: string, run: () => Promise<void>): Promise<void> => {
150
+ try {
151
+ await run()
152
+ } catch (error: unknown) {
153
+ workerLogger.error('cron step failed', { step: name, error: String(error) })
154
+ }
155
+ }
113
156
  ctx.waitUntil(
114
157
  (async () => {
115
- const { sealed, repaired } = await kernel.turns.sweep()
116
- if (sealed > 0 || repaired > 0) {
117
- workerLogger.info('cron advanced turns', { sealed, repaired })
118
- }
119
- await kernel.retention.collect()
158
+ await step('turns', async () => {
159
+ const { sealed, repaired } = await kernel.turns.sweep()
160
+ if (sealed > 0 || repaired > 0) {
161
+ workerLogger.info('cron advanced turns', { sealed, repaired })
162
+ }
163
+ })
164
+ await step('retention', () => kernel.retention.collect().then(() => undefined))
120
165
  // A separate database with a separate window; nothing about a match's retention decides
121
166
  // when a bug report and its R2 object go.
122
- await container.bugReports?.collect()
123
- })().catch((error: unknown) => {
124
- workerLogger.error('cron sweep failed', { error: String(error) })
125
- }),
167
+ await step('bugReports', async () => {
168
+ await container.bugReports?.collect()
169
+ })
170
+ })(),
126
171
  )
127
172
  },
128
173
  }
package/src/kernel.ts CHANGED
@@ -30,6 +30,12 @@ const DAY_MS = 24 * 60 * 60 * 1000
30
30
  const DEFAULT_RETENTION_DAYS = 30
31
31
  const DEFAULT_ABANDONED_RETENTION_DAYS = 90
32
32
 
33
+ /** A non-negative whole number from an environment variable; anything else reads as `fallback`. */
34
+ function wholeNumber(raw: string | undefined, fallback: number): number {
35
+ const value = Number(raw ?? fallback)
36
+ return Number.isInteger(value) && value >= 0 ? value : fallback
37
+ }
38
+
33
39
  export const HUB_PATHS = {
34
40
  notify: '/notify',
35
41
  schedule: '/schedule',
@@ -43,6 +49,39 @@ export function hubFor(env: Env, matchId: string) {
43
49
  return env.MATCH_HUB.get(env.MATCH_HUB.idFromName(matchId))
44
50
  }
45
51
 
52
+ /**
53
+ * How long a call into a match's Durable Object may take before the caller gives up on it.
54
+ *
55
+ * Every one of them is best effort by design — fan-out, arming a deadline, hanging up a revoked
56
+ * membership — and each has a safety net behind it: the stream's own catch-up drain, the sweep's
57
+ * `listExpiredOpen`, and the revoked token itself. What they did not have was an end. An object
58
+ * that is slow to wake, being relocated, or holding a lock kept the REQUEST waiting, so a seal
59
+ * completed in the database and the submitter saw a timeout.
60
+ */
61
+ const HUB_CALL_TIMEOUT_MS = 5_000
62
+
63
+ /**
64
+ * A best-effort call into a hub: bounded in time, and its failure logged rather than raised.
65
+ *
66
+ * The step it belongs to has already committed whatever was durable about it, so the only thing a
67
+ * failure here can still cost is latency somebody else's retry or the sweep already covers.
68
+ */
69
+ async function tellHub(
70
+ env: Env,
71
+ call: { matchId: string; path: string; body: unknown },
72
+ ): Promise<void> {
73
+ const { matchId, path, body } = call
74
+ try {
75
+ await hubFor(env, matchId).fetch(`https://hub${path}`, {
76
+ method: 'POST',
77
+ body: JSON.stringify(body),
78
+ signal: AbortSignal.timeout(HUB_CALL_TIMEOUT_MS),
79
+ })
80
+ } catch (error) {
81
+ workerLogger.warn('could not reach the match hub', { matchId, path, error: String(error) })
82
+ }
83
+ }
84
+
46
85
  /**
47
86
  * Builds the kernel over D1 for one invocation. Fan-out and alarms cross into the match's Durable
48
87
  * Object; everything else is plain D1 through the shared SQLite repositories.
@@ -57,32 +96,25 @@ export function buildKernel(
57
96
  ): Kernel {
58
97
  const storage = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
59
98
  const notifier: EventNotifier = overrides.notifier ?? {
60
- notify: async (event) => {
61
- await hubFor(env, event.matchId).fetch(`https://hub${HUB_PATHS.notify}`, {
62
- method: 'POST',
63
- body: JSON.stringify({ matchId: event.matchId }),
64
- })
65
- },
99
+ // The whole durable row, not just the match id. The object formats it once and hands the frame
100
+ // to every stream of the match, so a seal's burst costs it no reads at all; being told only
101
+ // which match had changed meant each subscriber queried D1 for rows the notification was
102
+ // already carrying. The event has been persisted before this runs, so the body crossing the
103
+ // isolate boundary is a copy of a fact, never a substitute for one.
104
+ notify: async (event) =>
105
+ tellHub(env, { matchId: event.matchId, path: HUB_PATHS.notify, body: event }),
66
106
  }
67
107
  const streams: StreamCloser = overrides.streams ?? {
68
- close: async (input) => {
69
- await hubFor(env, input.matchId).fetch(`https://hub${HUB_PATHS.disconnect}`, {
70
- method: 'POST',
71
- body: JSON.stringify(input),
72
- })
73
- },
108
+ close: async (input) =>
109
+ tellHub(env, { matchId: input.matchId, path: HUB_PATHS.disconnect, body: input }),
74
110
  }
75
111
  const scheduler: DeadlineScheduler = overrides.scheduler ?? {
76
- schedule: async (input) => {
77
- await hubFor(env, input.matchId).fetch(`https://hub${HUB_PATHS.schedule}`, {
78
- method: 'POST',
79
- body: JSON.stringify({ ...input, dueAt: input.dueAt.toISOString() }),
80
- })
81
- },
82
- }
83
- const days = (raw: string | undefined, fallback: number): number => {
84
- const value = Number(raw ?? fallback)
85
- return Number.isInteger(value) && value >= 0 ? value : fallback
112
+ schedule: async (input) =>
113
+ tellHub(env, {
114
+ matchId: input.matchId,
115
+ path: HUB_PATHS.schedule,
116
+ body: { ...input, dueAt: input.dueAt.toISOString() },
117
+ }),
86
118
  }
87
119
  return createKernel(
88
120
  {
@@ -95,9 +127,13 @@ export function buildKernel(
95
127
  },
96
128
  {
97
129
  retention: {
98
- maxAgeMs: days(env.RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * DAY_MS,
130
+ maxAgeMs: wholeNumber(env.RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * DAY_MS,
99
131
  abandonedLiveMaxAgeMs:
100
- days(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * DAY_MS,
132
+ wholeNumber(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * DAY_MS,
133
+ // Twice the abandoned window, and without its roster test, which never collects an untimed
134
+ // match whose players' clients died without a `leave`.
135
+ silentLiveMaxAgeMs:
136
+ wholeNumber(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * 2 * DAY_MS,
101
137
  batchSize: 50,
102
138
  },
103
139
  },
@@ -119,10 +155,6 @@ export function buildBugReports(env: Env): BugReportService | undefined {
119
155
  if (!env.BUG_DB) return undefined
120
156
  const repository = createBugReportRepository(drizzle(env.BUG_DB, { schema: bugReportSchema }))
121
157
  const blobs = env.BUG_BLOBS ? createR2BlobStore(env.BUG_BLOBS) : undefined
122
- const number = (raw: string | undefined, fallback: number): number => {
123
- const value = Number(raw ?? fallback)
124
- return Number.isInteger(value) && value >= 0 ? value : fallback
125
- }
126
158
  return createBugReportService({
127
159
  repository,
128
160
  clock: { now: () => new Date() },
@@ -130,14 +162,14 @@ export function buildBugReports(env: Env): BugReportService | undefined {
130
162
  retention: {
131
163
  ...DEFAULT_BUG_REPORT_RETENTION,
132
164
  dailyStateBytes:
133
- number(
165
+ wholeNumber(
134
166
  env.BUG_REPORT_DAILY_STATE_MB,
135
167
  DEFAULT_BUG_REPORT_RETENTION.dailyStateBytes / (1024 * 1024),
136
168
  ) *
137
169
  1024 *
138
170
  1024,
139
171
  maxAgeMs:
140
- number(env.BUG_REPORT_RETENTION_DAYS, DEFAULT_BUG_REPORT_RETENTION.maxAgeMs / DAY_MS) *
172
+ wholeNumber(env.BUG_REPORT_RETENTION_DAYS, DEFAULT_BUG_REPORT_RETENTION.maxAgeMs / DAY_MS) *
141
173
  DAY_MS,
142
174
  },
143
175
  ...(blobs ? { blobs } : {}),