@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 +15 -0
- package/package.json +12 -12
- package/src/MatchHub.ts +7 -4
- package/src/index.ts +54 -9
- package/src/kernel.ts +52 -18
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.
|
|
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.
|
|
51
|
-
"@chaos-overlords/contracts": "0.8.
|
|
52
|
-
"@chaos-overlords/kernel": "0.8.
|
|
53
|
-
"@chaos-overlords/server": "0.8.
|
|
54
|
-
"@chaos-overlords/storage": "0.8.
|
|
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.
|
|
56
|
+
"hono": "^4.13.8"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@chaos-overlords/client": "0.8.
|
|
60
|
-
"@chaos-overlords/conformance": "0.8.
|
|
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.
|
|
63
|
-
"typescript": "^
|
|
62
|
+
"@cloudflare/workers-types": "^5.20260920.1",
|
|
63
|
+
"typescript": "^7.0.2",
|
|
64
64
|
"vitest": "^4.1.11",
|
|
65
|
-
"wrangler": "^4.
|
|
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
|
|
37
|
-
|
|
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.
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
},
|