@chaos-overlords/worker 0.8.1 → 0.8.2

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.8.2",
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.8.2",
51
+ "@chaos-overlords/contracts": "0.8.2",
52
+ "@chaos-overlords/kernel": "0.8.2",
53
+ "@chaos-overlords/server": "0.8.2",
54
+ "@chaos-overlords/storage": "0.8.2",
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.8.2",
60
+ "@chaos-overlords/conformance": "0.8.2",
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,4 @@
1
- import { isDomainError } from '@chaos-overlords/kernel'
1
+ import { isDomainError, type PersistedEvent } from '@chaos-overlords/kernel'
2
2
  import { DEFAULT_SERVER_CONFIG, LocalEventHub } from '@chaos-overlords/server'
3
3
  import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
4
4
  import type { DurableObjectState } from '@cloudflare/workers-types'
@@ -33,8 +33,11 @@ export class MatchHub {
33
33
  const url = new URL(request.url)
34
34
  switch (url.pathname) {
35
35
  case HUB_PATHS.notify: {
36
- const { matchId } = (await request.json()) as { matchId: string }
37
- this.hub.wake(matchId)
36
+ const event = (await request.json()) as PersistedEvent & { matchId?: string }
37
+ // The event body when the caller sent one; a bare match id is still accepted, so an isolate
38
+ // running an older build cannot silence this object's streams.
39
+ if (typeof event.seq === 'number') await this.hub.notify(event)
40
+ else if (event.matchId) this.hub.wake(event.matchId)
38
41
  return new Response(null, { status: 204 })
39
42
  }
40
43
  case HUB_PATHS.schedule: {
@@ -78,7 +81,7 @@ export class MatchHub {
78
81
  if (!pending) return
79
82
  const kernel = buildKernel(this.env, {
80
83
  // Already inside the hub: wake local streams directly instead of calling ourselves.
81
- notifier: { notify: async (event) => this.hub.wake(event.matchId) },
84
+ notifier: { notify: async (event) => this.hub.notify(event) },
82
85
  streams: this.hub,
83
86
  scheduler: {
84
87
  schedule: async (input) => {
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
@@ -43,6 +43,39 @@ export function hubFor(env: Env, matchId: string) {
43
43
  return env.MATCH_HUB.get(env.MATCH_HUB.idFromName(matchId))
44
44
  }
45
45
 
46
+ /**
47
+ * How long a call into a match's Durable Object may take before the caller gives up on it.
48
+ *
49
+ * Every one of them is best effort by design — fan-out, arming a deadline, hanging up a revoked
50
+ * membership — and each has a safety net behind it: the stream's own catch-up drain, the sweep's
51
+ * `listExpiredOpen`, and the revoked token itself. What they did not have was an end. An object
52
+ * that is slow to wake, being relocated, or holding a lock kept the REQUEST waiting, so a seal
53
+ * completed in the database and the submitter saw a timeout.
54
+ */
55
+ const HUB_CALL_TIMEOUT_MS = 5_000
56
+
57
+ /**
58
+ * A best-effort call into a hub: bounded in time, and its failure logged rather than raised.
59
+ *
60
+ * The step it belongs to has already committed whatever was durable about it, so the only thing a
61
+ * failure here can still cost is latency somebody else's retry or the sweep already covers.
62
+ */
63
+ async function tellHub(
64
+ env: Env,
65
+ call: { matchId: string; path: string; body: unknown },
66
+ ): Promise<void> {
67
+ const { matchId, path, body } = call
68
+ try {
69
+ await hubFor(env, matchId).fetch(`https://hub${path}`, {
70
+ method: 'POST',
71
+ body: JSON.stringify(body),
72
+ signal: AbortSignal.timeout(HUB_CALL_TIMEOUT_MS),
73
+ })
74
+ } catch (error) {
75
+ workerLogger.warn('could not reach the match hub', { matchId, path, error: String(error) })
76
+ }
77
+ }
78
+
46
79
  /**
47
80
  * Builds the kernel over D1 for one invocation. Fan-out and alarms cross into the match's Durable
48
81
  * Object; everything else is plain D1 through the shared SQLite repositories.
@@ -57,28 +90,25 @@ export function buildKernel(
57
90
  ): Kernel {
58
91
  const storage = createSqliteStorage(drizzle(env.DB, { schema: sqliteSchema }))
59
92
  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
- },
93
+ // The whole durable row, not just the match id. The object formats it once and hands the frame
94
+ // to every stream of the match, so a seal's burst costs it no reads at all; being told only
95
+ // which match had changed meant each subscriber queried D1 for rows the notification was
96
+ // already carrying. The event has been persisted before this runs, so the body crossing the
97
+ // isolate boundary is a copy of a fact, never a substitute for one.
98
+ notify: async (event) =>
99
+ tellHub(env, { matchId: event.matchId, path: HUB_PATHS.notify, body: event }),
66
100
  }
67
101
  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
- },
102
+ close: async (input) =>
103
+ tellHub(env, { matchId: input.matchId, path: HUB_PATHS.disconnect, body: input }),
74
104
  }
75
105
  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
- },
106
+ schedule: async (input) =>
107
+ tellHub(env, {
108
+ matchId: input.matchId,
109
+ path: HUB_PATHS.schedule,
110
+ body: { ...input, dueAt: input.dueAt.toISOString() },
111
+ }),
82
112
  }
83
113
  const days = (raw: string | undefined, fallback: number): number => {
84
114
  const value = Number(raw ?? fallback)
@@ -98,6 +128,10 @@ export function buildKernel(
98
128
  maxAgeMs: days(env.RETENTION_DAYS, DEFAULT_RETENTION_DAYS) * DAY_MS,
99
129
  abandonedLiveMaxAgeMs:
100
130
  days(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * DAY_MS,
131
+ // Twice the abandoned window, and without its roster test, which never collects an untimed
132
+ // match whose players' clients died without a `leave`.
133
+ silentLiveMaxAgeMs:
134
+ days(env.ABANDONED_RETENTION_DAYS, DEFAULT_ABANDONED_RETENTION_DAYS) * 2 * DAY_MS,
101
135
  batchSize: 50,
102
136
  },
103
137
  },