@chaos-overlords/worker 0.9.2 → 0.9.4

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.
Files changed (2) hide show
  1. package/package.json +8 -8
  2. package/src/MatchHub.ts +29 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaos-overlords/worker",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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,17 +47,17 @@
47
47
  "provenance": true
48
48
  },
49
49
  "dependencies": {
50
- "@chaos-overlords/bug-reports": "0.9.2",
51
- "@chaos-overlords/contracts": "0.9.2",
52
- "@chaos-overlords/kernel": "0.9.2",
53
- "@chaos-overlords/server": "0.9.2",
54
- "@chaos-overlords/storage": "0.9.2",
50
+ "@chaos-overlords/bug-reports": "0.9.4",
51
+ "@chaos-overlords/contracts": "0.9.4",
52
+ "@chaos-overlords/kernel": "0.9.4",
53
+ "@chaos-overlords/server": "0.9.4",
54
+ "@chaos-overlords/storage": "0.9.4",
55
55
  "drizzle-orm": "^0.45.2",
56
56
  "hono": "^4.13.8"
57
57
  },
58
58
  "devDependencies": {
59
- "@chaos-overlords/client": "0.9.2",
60
- "@chaos-overlords/conformance": "0.9.2",
59
+ "@chaos-overlords/client": "0.9.4",
60
+ "@chaos-overlords/conformance": "0.9.4",
61
61
  "@cloudflare/vitest-pool-workers": "^0.22.0",
62
62
  "@cloudflare/workers-types": "^5.20260920.1",
63
63
  "typescript": "^7.0.2",
package/src/MatchHub.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ EARLY_DEADLINE_RETRY_MS,
2
3
  isDomainError,
3
4
  type MultiplayerStorage,
4
5
  type PersistedEvent,
@@ -8,6 +9,7 @@ import {
8
9
  DEFAULT_SERVER_CONFIG,
9
10
  isActiveMember,
10
11
  LocalEventHub,
12
+ logStreamClosed,
11
13
  } from '@chaos-overlords/server'
12
14
  import { createSqliteStorage, sqliteSchema } from '@chaos-overlords/storage/sqlite'
13
15
  import type { DurableObjectState } from '@cloudflare/workers-types'
@@ -18,10 +20,33 @@ import { buildKernel, HUB_PATHS, workerLogger } from './kernel'
18
20
  interface PendingDeadline {
19
21
  matchId: string
20
22
  turn: number
23
+ /** When the alarm was set for. Absent on keys written before it was recorded. */
24
+ dueAtMs?: number
21
25
  }
22
26
 
23
27
  const DEADLINE_KEY = 'deadline'
24
28
 
29
+ /**
30
+ * Where a deadline the kernel re-arms from inside an alarm may be set for.
31
+ *
32
+ * An alarm that the kernel finds early is re-armed for the same turn, floored against the object's
33
+ * `Date.now()`. That floor means nothing to the alarm scheduler when the object's clock lags it:
34
+ * the scheduler has already passed the time the alarm fired at, so a deadline at or before that
35
+ * time fires again at once, and again, building a kernel and reading D1 each time until the
36
+ * object's clock catches up. Having fired proves the scheduler's clock reached `fired.dueAtMs`, so
37
+ * the retry goes at least the kernel's retry interval past that, which bounds the rate on the
38
+ * scheduler's own clock. Any other turn's deadline (the successor a seal opened) is set as asked.
39
+ */
40
+ function retryFloor(
41
+ fired: PendingDeadline,
42
+ next: { matchId: string; turn: number },
43
+ dueAtMs: number,
44
+ ): number {
45
+ const sameTurn = next.matchId === fired.matchId && next.turn === fired.turn
46
+ if (!sameTurn || fired.dueAtMs === undefined) return dueAtMs
47
+ return Math.max(dueAtMs, fired.dueAtMs + EARLY_DEADLINE_RETRY_MS)
48
+ }
49
+
25
50
  /**
26
51
  * One instance per match. It holds the open event streams of that match (so a notification from
27
52
  * any Worker isolate reaches every subscriber) and the alarm for the open turn's deadline. The
@@ -47,6 +72,7 @@ export class MatchHub {
47
72
  workerLogger.info('answered an already abandoned event stream', { matchId, playerId }),
48
73
  revalidate: (matchId, playerId) =>
49
74
  isActiveMember(this.repositories.players, matchId, playerId),
75
+ closed: logStreamClosed(workerLogger),
50
76
  },
51
77
  )
52
78
  }
@@ -102,7 +128,8 @@ export class MatchHub {
102
128
  notifier: { notify: async (event) => this.hub.notify(event) },
103
129
  streams: this.hub,
104
130
  scheduler: {
105
- schedule: async (input) => this.arm(input.matchId, input.turn, input.dueAt.getTime()),
131
+ schedule: async (input) =>
132
+ this.arm(input.matchId, input.turn, retryFloor(pending, input, input.dueAt.getTime())),
106
133
  },
107
134
  })
108
135
  const sealed = await kernel.turns.trySeal(pending.matchId, pending.turn, 'deadline')
@@ -112,7 +139,7 @@ export class MatchHub {
112
139
 
113
140
  /** Record which turn the alarm is for, then set the alarm. One deadline is pending at a time. */
114
141
  private async arm(matchId: string, turn: number, dueAtMs: number): Promise<void> {
115
- await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, { matchId, turn })
142
+ await this.state.storage.put<PendingDeadline>(DEADLINE_KEY, { matchId, turn, dueAtMs })
116
143
  await this.state.storage.setAlarm(dueAtMs)
117
144
  }
118
145