@rivetkit/engine-runner 25.7.1-rc.1
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/.turbo/turbo-build.log +23 -0
- package/.turbo/turbo-check-types.log +5 -0
- package/.turbo/turbo-test.log +5537 -0
- package/benches/actor-lifecycle.bench.ts +190 -0
- package/benches/utils.ts +143 -0
- package/dist/mod.cjs +2044 -0
- package/dist/mod.cjs.map +1 -0
- package/dist/mod.d.cts +67 -0
- package/dist/mod.d.ts +67 -0
- package/dist/mod.js +2044 -0
- package/dist/mod.js.map +1 -0
- package/package.json +38 -0
- package/src/log.ts +11 -0
- package/src/mod.ts +1354 -0
- package/src/tunnel.ts +841 -0
- package/src/utils.ts +31 -0
- package/src/websocket-tunnel-adapter.ts +486 -0
- package/src/websocket.ts +43 -0
- package/tests/lifecycle.test.ts +596 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +4 -0
- package/turbo.json +4 -0
- package/vitest.config.ts +17 -0
package/src/mod.ts
ADDED
|
@@ -0,0 +1,1354 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { importWebSocket } from "./websocket.js";
|
|
3
|
+
import * as protocol from "@rivetkit/engine-runner-protocol";
|
|
4
|
+
import { unreachable, calculateBackoff } from "./utils";
|
|
5
|
+
import { Tunnel } from "./tunnel";
|
|
6
|
+
import { WebSocketTunnelAdapter } from "./websocket-tunnel-adapter";
|
|
7
|
+
import type { Logger } from "pino";
|
|
8
|
+
import { setLogger, logger } from "./log.js";
|
|
9
|
+
|
|
10
|
+
const KV_EXPIRE: number = 30_000;
|
|
11
|
+
|
|
12
|
+
export interface ActorInstance {
|
|
13
|
+
actorId: string;
|
|
14
|
+
generation: number;
|
|
15
|
+
config: ActorConfig;
|
|
16
|
+
requests: Set<string>; // Track active request IDs
|
|
17
|
+
webSockets: Set<string>; // Track active WebSocket IDs
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ActorConfig {
|
|
21
|
+
name: string;
|
|
22
|
+
key: string | null;
|
|
23
|
+
createTs: bigint;
|
|
24
|
+
input: Uint8Array | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RunnerConfig {
|
|
28
|
+
logger?: Logger;
|
|
29
|
+
version: number;
|
|
30
|
+
endpoint: string;
|
|
31
|
+
pegboardEndpoint?: string;
|
|
32
|
+
pegboardRelayEndpoint?: string;
|
|
33
|
+
namespace: string;
|
|
34
|
+
totalSlots: number;
|
|
35
|
+
runnerName: string;
|
|
36
|
+
runnerKey: string;
|
|
37
|
+
prepopulateActorNames: Record<string, { metadata: Record<string, any> }>;
|
|
38
|
+
metadata?: Record<string, any>;
|
|
39
|
+
onConnected: () => void;
|
|
40
|
+
onDisconnected: () => void;
|
|
41
|
+
onShutdown: () => void;
|
|
42
|
+
fetch: (actorId: string, request: Request) => Promise<Response>;
|
|
43
|
+
websocket?: (actorId: string, ws: any, request: Request) => Promise<void>;
|
|
44
|
+
onActorStart: (
|
|
45
|
+
actorId: string,
|
|
46
|
+
generation: number,
|
|
47
|
+
config: ActorConfig,
|
|
48
|
+
) => Promise<void>;
|
|
49
|
+
onActorStop: (actorId: string, generation: number) => Promise<void>;
|
|
50
|
+
noAutoShutdown?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface KvListOptions {
|
|
54
|
+
reverse?: boolean;
|
|
55
|
+
limit?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface KvRequestEntry {
|
|
59
|
+
actorId: string;
|
|
60
|
+
data: protocol.KvRequestData;
|
|
61
|
+
resolve: (value: any) => void;
|
|
62
|
+
reject: (error: unknown) => void;
|
|
63
|
+
sent: boolean;
|
|
64
|
+
timestamp: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class Runner {
|
|
68
|
+
#config: RunnerConfig;
|
|
69
|
+
|
|
70
|
+
get config(): RunnerConfig {
|
|
71
|
+
return this.#config;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#actors: Map<string, ActorInstance> = new Map();
|
|
75
|
+
#actorWebSockets: Map<string, Set<WebSocketTunnelAdapter>> = new Map();
|
|
76
|
+
|
|
77
|
+
// WebSocket
|
|
78
|
+
#pegboardWebSocket?: WebSocket;
|
|
79
|
+
runnerId?: string;
|
|
80
|
+
#lastCommandIdx: number = -1;
|
|
81
|
+
#pingLoop?: NodeJS.Timeout;
|
|
82
|
+
#nextEventIdx: bigint = 0n;
|
|
83
|
+
#started: boolean = false;
|
|
84
|
+
#shutdown: boolean = false;
|
|
85
|
+
#reconnectAttempt: number = 0;
|
|
86
|
+
#reconnectTimeout?: NodeJS.Timeout;
|
|
87
|
+
|
|
88
|
+
// Runner lost threshold management
|
|
89
|
+
#runnerLostThreshold?: number;
|
|
90
|
+
#runnerLostTimeout?: NodeJS.Timeout;
|
|
91
|
+
|
|
92
|
+
// Event storage for resending
|
|
93
|
+
#eventHistory: { event: protocol.EventWrapper; timestamp: number }[] = [];
|
|
94
|
+
#eventPruneInterval?: NodeJS.Timeout;
|
|
95
|
+
|
|
96
|
+
// Command acknowledgment
|
|
97
|
+
#ackInterval?: NodeJS.Timeout;
|
|
98
|
+
|
|
99
|
+
// KV operations
|
|
100
|
+
#nextRequestId: number = 0;
|
|
101
|
+
#kvRequests: Map<number, KvRequestEntry> = new Map();
|
|
102
|
+
#kvCleanupInterval?: NodeJS.Timeout;
|
|
103
|
+
|
|
104
|
+
// Tunnel for HTTP/WebSocket forwarding
|
|
105
|
+
#tunnel?: Tunnel;
|
|
106
|
+
|
|
107
|
+
constructor(config: RunnerConfig) {
|
|
108
|
+
this.#config = config;
|
|
109
|
+
if (this.#config.logger) setLogger(this.#config.logger);
|
|
110
|
+
|
|
111
|
+
// TODO(RVT-4986): Prune when server acks events
|
|
112
|
+
// Start pruning old events every minute
|
|
113
|
+
this.#eventPruneInterval = setInterval(() => {
|
|
114
|
+
this.#pruneOldEvents();
|
|
115
|
+
}, 60000); // Run every minute
|
|
116
|
+
|
|
117
|
+
// Start cleaning up old unsent KV requests every 15 seconds
|
|
118
|
+
this.#kvCleanupInterval = setInterval(() => {
|
|
119
|
+
this.#cleanupOldKvRequests();
|
|
120
|
+
}, 15000); // Run every 15 seconds
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// MARK: Manage actors
|
|
124
|
+
sleepActor(actorId: string, generation?: number) {
|
|
125
|
+
const actor = this.getActor(actorId, generation);
|
|
126
|
+
if (!actor) return;
|
|
127
|
+
|
|
128
|
+
// Keep the actor instance in memory during sleep
|
|
129
|
+
this.#sendActorIntent(actorId, actor.generation, "sleep");
|
|
130
|
+
|
|
131
|
+
// NOTE: We do NOT remove the actor from this.#actors here
|
|
132
|
+
// The server will send a StopActor command if it wants to fully stop
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async stopActor(actorId: string, generation?: number) {
|
|
136
|
+
const actor = this.#removeActor(actorId, generation);
|
|
137
|
+
if (!actor) return;
|
|
138
|
+
|
|
139
|
+
// Unregister actor from tunnel
|
|
140
|
+
if (this.#tunnel) {
|
|
141
|
+
this.#tunnel.unregisterActor(actor);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// If onActorStop times out, Pegboard will handle this timeout with ACTOR_STOP_THRESHOLD_DURATION_MS
|
|
145
|
+
try {
|
|
146
|
+
await this.#config.onActorStop(actorId, actor.generation);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(`Error in onActorStop for actor ${actorId}:`, err);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.#sendActorStateUpdate(actorId, actor.generation, "stopped");
|
|
152
|
+
|
|
153
|
+
this.#config.onActorStop(actorId, actor.generation).catch((err) => {
|
|
154
|
+
logger()?.error({
|
|
155
|
+
msg: "error in onactorstop for actor",
|
|
156
|
+
actorId,
|
|
157
|
+
err,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#stopAllActors() {
|
|
163
|
+
logger()?.info(
|
|
164
|
+
"stopping all actors due to runner lost threshold exceeded",
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const actorIds = Array.from(this.#actors.keys());
|
|
168
|
+
for (const actorId of actorIds) {
|
|
169
|
+
this.stopActor(actorId);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getActor(actorId: string, generation?: number): ActorInstance | undefined {
|
|
174
|
+
const actor = this.#actors.get(actorId);
|
|
175
|
+
if (!actor) {
|
|
176
|
+
logger()?.error({ msg: "actor not found", actorId });
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
if (generation !== undefined && actor.generation !== generation) {
|
|
180
|
+
logger()?.error({
|
|
181
|
+
msg: "actor generation mismatch",
|
|
182
|
+
actorId,
|
|
183
|
+
generation,
|
|
184
|
+
});
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return actor;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
hasActor(actorId: string, generation?: number): boolean {
|
|
192
|
+
const actor = this.#actors.get(actorId);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
!!actor &&
|
|
196
|
+
(generation === undefined || actor.generation === generation)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#removeActor(
|
|
201
|
+
actorId: string,
|
|
202
|
+
generation?: number,
|
|
203
|
+
): ActorInstance | undefined {
|
|
204
|
+
const actor = this.#actors.get(actorId);
|
|
205
|
+
if (!actor) {
|
|
206
|
+
logger()?.error({ msg: "actor not found", actorId });
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
if (generation !== undefined && actor.generation !== generation) {
|
|
210
|
+
logger()?.error({
|
|
211
|
+
msg: "actor generation mismatch",
|
|
212
|
+
actorId,
|
|
213
|
+
generation,
|
|
214
|
+
});
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.#actors.delete(actorId);
|
|
219
|
+
|
|
220
|
+
// Close all WebSocket connections for this actor
|
|
221
|
+
const actorWebSockets = this.#actorWebSockets.get(actorId);
|
|
222
|
+
if (actorWebSockets) {
|
|
223
|
+
for (const ws of actorWebSockets) {
|
|
224
|
+
try {
|
|
225
|
+
ws.close(1000, "Actor stopped");
|
|
226
|
+
} catch (err) {
|
|
227
|
+
logger()?.error({
|
|
228
|
+
msg: "error closing websocket for actor",
|
|
229
|
+
actorId,
|
|
230
|
+
err,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
this.#actorWebSockets.delete(actorId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return actor;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// MARK: Start
|
|
241
|
+
async start() {
|
|
242
|
+
if (this.#started) throw new Error("Cannot call runner.start twice");
|
|
243
|
+
this.#started = true;
|
|
244
|
+
|
|
245
|
+
logger()?.info("starting runner");
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// Connect tunnel first and wait for it to be ready before connecting runner WebSocket
|
|
249
|
+
// This prevents a race condition where the runner appears ready but can't accept network requests
|
|
250
|
+
await this.#openTunnelAndWait();
|
|
251
|
+
await this.#openPegboardWebSocket();
|
|
252
|
+
} catch (error) {
|
|
253
|
+
this.#started = false;
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!this.#config.noAutoShutdown) {
|
|
258
|
+
process.on("SIGTERM", this.shutdown.bind(this, false, true));
|
|
259
|
+
process.on("SIGINT", this.shutdown.bind(this, false, true));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// MARK: Shutdown
|
|
264
|
+
async shutdown(immediate: boolean, exit: boolean = false) {
|
|
265
|
+
logger()?.info({ msg: "starting shutdown...", immediate });
|
|
266
|
+
this.#shutdown = true;
|
|
267
|
+
|
|
268
|
+
// Clear reconnect timeout
|
|
269
|
+
if (this.#reconnectTimeout) {
|
|
270
|
+
clearTimeout(this.#reconnectTimeout);
|
|
271
|
+
this.#reconnectTimeout = undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Clear runner lost timeout
|
|
275
|
+
if (this.#runnerLostTimeout) {
|
|
276
|
+
clearTimeout(this.#runnerLostTimeout);
|
|
277
|
+
this.#runnerLostTimeout = undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Clear ping loop
|
|
281
|
+
if (this.#pingLoop) {
|
|
282
|
+
clearInterval(this.#pingLoop);
|
|
283
|
+
this.#pingLoop = undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Clear ack interval
|
|
287
|
+
if (this.#ackInterval) {
|
|
288
|
+
clearInterval(this.#ackInterval);
|
|
289
|
+
this.#ackInterval = undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Clear event prune interval
|
|
293
|
+
if (this.#eventPruneInterval) {
|
|
294
|
+
clearInterval(this.#eventPruneInterval);
|
|
295
|
+
this.#eventPruneInterval = undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Clear KV cleanup interval
|
|
299
|
+
if (this.#kvCleanupInterval) {
|
|
300
|
+
clearInterval(this.#kvCleanupInterval);
|
|
301
|
+
this.#kvCleanupInterval = undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Reject all KV requests
|
|
305
|
+
for (const request of this.#kvRequests.values()) {
|
|
306
|
+
request.reject(
|
|
307
|
+
new Error("WebSocket connection closed during shutdown"),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
this.#kvRequests.clear();
|
|
311
|
+
|
|
312
|
+
// Close WebSocket
|
|
313
|
+
if (
|
|
314
|
+
this.#pegboardWebSocket &&
|
|
315
|
+
this.#pegboardWebSocket.readyState === WebSocket.OPEN
|
|
316
|
+
) {
|
|
317
|
+
const pegboardWebSocket = this.#pegboardWebSocket;
|
|
318
|
+
if (immediate) {
|
|
319
|
+
// Stop immediately
|
|
320
|
+
pegboardWebSocket.close(1000, "Stopping");
|
|
321
|
+
} else {
|
|
322
|
+
// Wait for actors to shut down before stopping
|
|
323
|
+
try {
|
|
324
|
+
logger()?.info({
|
|
325
|
+
msg: "sending stopping message",
|
|
326
|
+
readyState: pegboardWebSocket.readyState,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// NOTE: We don't use #sendToServer here because that function checks if the runner is
|
|
330
|
+
// shut down
|
|
331
|
+
const encoded = protocol.encodeToServer({
|
|
332
|
+
tag: "ToServerStopping",
|
|
333
|
+
val: null,
|
|
334
|
+
});
|
|
335
|
+
if (
|
|
336
|
+
this.#pegboardWebSocket &&
|
|
337
|
+
this.#pegboardWebSocket.readyState === WebSocket.OPEN
|
|
338
|
+
) {
|
|
339
|
+
this.#pegboardWebSocket.send(encoded);
|
|
340
|
+
} else {
|
|
341
|
+
logger()?.error(
|
|
342
|
+
"WebSocket not available or not open for sending data",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const closePromise = new Promise<void>((resolve) => {
|
|
347
|
+
if (!pegboardWebSocket)
|
|
348
|
+
throw new Error("missing pegboardWebSocket");
|
|
349
|
+
|
|
350
|
+
pegboardWebSocket.addEventListener("close", (ev) => {
|
|
351
|
+
logger()?.info({
|
|
352
|
+
msg: "connection closed",
|
|
353
|
+
code: ev.code,
|
|
354
|
+
reason: ev.reason.toString(),
|
|
355
|
+
});
|
|
356
|
+
resolve();
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// TODO: Wait for all actors to stop before closing ws
|
|
361
|
+
|
|
362
|
+
logger()?.info("closing WebSocket");
|
|
363
|
+
pegboardWebSocket.close(1000, "Stopping");
|
|
364
|
+
|
|
365
|
+
await closePromise;
|
|
366
|
+
|
|
367
|
+
logger()?.info("websocket shutdown completed");
|
|
368
|
+
} catch (error) {
|
|
369
|
+
logger()?.error({
|
|
370
|
+
msg: "error during websocket shutdown:",
|
|
371
|
+
error,
|
|
372
|
+
});
|
|
373
|
+
pegboardWebSocket.close();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
logger()?.warn("no runner WebSocket to shutdown or already closed");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Close tunnel
|
|
381
|
+
if (this.#tunnel) {
|
|
382
|
+
this.#tunnel.shutdown();
|
|
383
|
+
logger()?.info("tunnel shutdown completed");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (exit) process.exit(0);
|
|
387
|
+
|
|
388
|
+
this.#config.onShutdown();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// MARK: Networking
|
|
392
|
+
get pegboardUrl() {
|
|
393
|
+
const endpoint = this.#config.pegboardEndpoint || this.#config.endpoint;
|
|
394
|
+
const wsEndpoint = endpoint
|
|
395
|
+
.replace("http://", "ws://")
|
|
396
|
+
.replace("https://", "wss://");
|
|
397
|
+
return `${wsEndpoint}?protocol_version=1&namespace=${encodeURIComponent(this.#config.namespace)}&runner_key=${encodeURIComponent(this.#config.runnerKey)}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
get pegboardTunnelUrl() {
|
|
401
|
+
const endpoint =
|
|
402
|
+
this.#config.pegboardRelayEndpoint ||
|
|
403
|
+
this.#config.pegboardEndpoint ||
|
|
404
|
+
this.#config.endpoint;
|
|
405
|
+
const wsEndpoint = endpoint
|
|
406
|
+
.replace("http://", "ws://")
|
|
407
|
+
.replace("https://", "wss://");
|
|
408
|
+
return `${wsEndpoint}?protocol_version=1&namespace=${encodeURIComponent(this.#config.namespace)}&runner_key=${this.#config.runnerKey}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async #openTunnelAndWait(): Promise<void> {
|
|
412
|
+
return new Promise((resolve, reject) => {
|
|
413
|
+
const url = this.pegboardTunnelUrl;
|
|
414
|
+
logger()?.info({ msg: "opening tunnel to:", url });
|
|
415
|
+
logger()?.info({
|
|
416
|
+
msg: "current runner id:",
|
|
417
|
+
runnerId: this.runnerId || "none",
|
|
418
|
+
});
|
|
419
|
+
logger()?.info({
|
|
420
|
+
msg: "active actors count:",
|
|
421
|
+
actors: this.#actors.size,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
let connected = false;
|
|
425
|
+
|
|
426
|
+
this.#tunnel = new Tunnel(this, url, {
|
|
427
|
+
onConnected: () => {
|
|
428
|
+
if (!connected) {
|
|
429
|
+
connected = true;
|
|
430
|
+
logger()?.info("tunnel connected");
|
|
431
|
+
resolve();
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
onDisconnected: () => {
|
|
435
|
+
if (!connected) {
|
|
436
|
+
// First connection attempt failed
|
|
437
|
+
reject(new Error("Tunnel connection failed"));
|
|
438
|
+
}
|
|
439
|
+
// If already connected, tunnel will handle reconnection automatically
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
this.#tunnel.start();
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// MARK: Runner protocol
|
|
447
|
+
async #openPegboardWebSocket() {
|
|
448
|
+
const WS = await importWebSocket();
|
|
449
|
+
const ws = new WS(this.pegboardUrl, {
|
|
450
|
+
headers: {
|
|
451
|
+
"x-rivet-target": "runner-ws",
|
|
452
|
+
},
|
|
453
|
+
}) as any as WebSocket;
|
|
454
|
+
this.#pegboardWebSocket = ws;
|
|
455
|
+
|
|
456
|
+
ws.addEventListener("open", () => {
|
|
457
|
+
logger()?.info("Connected");
|
|
458
|
+
|
|
459
|
+
// Reset reconnect attempt counter on successful connection
|
|
460
|
+
this.#reconnectAttempt = 0;
|
|
461
|
+
|
|
462
|
+
// Clear any pending reconnect timeout
|
|
463
|
+
if (this.#reconnectTimeout) {
|
|
464
|
+
clearTimeout(this.#reconnectTimeout);
|
|
465
|
+
this.#reconnectTimeout = undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Clear any pending runner lost timeout since we're reconnecting
|
|
469
|
+
if (this.#runnerLostTimeout) {
|
|
470
|
+
clearTimeout(this.#runnerLostTimeout);
|
|
471
|
+
this.#runnerLostTimeout = undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Send init message
|
|
475
|
+
const init: protocol.ToServerInit = {
|
|
476
|
+
name: this.#config.runnerName,
|
|
477
|
+
version: this.#config.version,
|
|
478
|
+
totalSlots: this.#config.totalSlots,
|
|
479
|
+
lastCommandIdx:
|
|
480
|
+
this.#lastCommandIdx >= 0
|
|
481
|
+
? BigInt(this.#lastCommandIdx)
|
|
482
|
+
: null,
|
|
483
|
+
prepopulateActorNames: new Map(
|
|
484
|
+
Object.entries(this.#config.prepopulateActorNames).map(
|
|
485
|
+
([name, data]) => [
|
|
486
|
+
name,
|
|
487
|
+
{ metadata: JSON.stringify(data.metadata) },
|
|
488
|
+
],
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
metadata: JSON.stringify(this.#config.metadata),
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
this.#sendToServer({
|
|
495
|
+
tag: "ToServerInit",
|
|
496
|
+
val: init,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Process unsent KV requests
|
|
500
|
+
this.#processUnsentKvRequests();
|
|
501
|
+
|
|
502
|
+
// Start ping interval
|
|
503
|
+
const pingInterval = 1000;
|
|
504
|
+
const pingLoop = setInterval(() => {
|
|
505
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
506
|
+
this.#sendToServer({
|
|
507
|
+
tag: "ToServerPing",
|
|
508
|
+
val: {
|
|
509
|
+
ts: BigInt(Date.now()),
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
} else {
|
|
513
|
+
clearInterval(pingLoop);
|
|
514
|
+
logger()?.info("WebSocket not open, stopping ping loop");
|
|
515
|
+
}
|
|
516
|
+
}, pingInterval);
|
|
517
|
+
this.#pingLoop = pingLoop;
|
|
518
|
+
|
|
519
|
+
// Start command acknowledgment interval (5 minutes)
|
|
520
|
+
const ackInterval = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
521
|
+
const ackLoop = setInterval(() => {
|
|
522
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
523
|
+
this.#sendCommandAcknowledgment();
|
|
524
|
+
} else {
|
|
525
|
+
clearInterval(ackLoop);
|
|
526
|
+
logger()?.info("WebSocket not open, stopping ack loop");
|
|
527
|
+
}
|
|
528
|
+
}, ackInterval);
|
|
529
|
+
this.#ackInterval = ackLoop;
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
ws.addEventListener("message", async (ev) => {
|
|
533
|
+
let buf;
|
|
534
|
+
if (ev.data instanceof Blob) {
|
|
535
|
+
buf = new Uint8Array(await ev.data.arrayBuffer());
|
|
536
|
+
} else if (Buffer.isBuffer(ev.data)) {
|
|
537
|
+
buf = new Uint8Array(ev.data);
|
|
538
|
+
} else {
|
|
539
|
+
throw new Error("expected binary data, got " + typeof ev.data);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Parse message
|
|
543
|
+
const message = protocol.decodeToClient(buf);
|
|
544
|
+
|
|
545
|
+
// Handle message
|
|
546
|
+
if (message.tag === "ToClientInit") {
|
|
547
|
+
const init = message.val;
|
|
548
|
+
const hadRunnerId = !!this.runnerId;
|
|
549
|
+
this.runnerId = init.runnerId;
|
|
550
|
+
|
|
551
|
+
// Store the runner lost threshold from metadata
|
|
552
|
+
this.#runnerLostThreshold = init.metadata?.runnerLostThreshold
|
|
553
|
+
? Number(init.metadata.runnerLostThreshold)
|
|
554
|
+
: undefined;
|
|
555
|
+
|
|
556
|
+
logger()?.info({
|
|
557
|
+
msg: "received init",
|
|
558
|
+
runnerId: init.runnerId,
|
|
559
|
+
lastEventIdx: init.lastEventIdx,
|
|
560
|
+
runnerLostThreshold: this.#runnerLostThreshold,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Resend events that haven't been acknowledged
|
|
564
|
+
this.#resendUnacknowledgedEvents(init.lastEventIdx);
|
|
565
|
+
|
|
566
|
+
this.#config.onConnected();
|
|
567
|
+
} else if (message.tag === "ToClientCommands") {
|
|
568
|
+
const commands = message.val;
|
|
569
|
+
this.#handleCommands(commands);
|
|
570
|
+
} else if (message.tag === "ToClientAckEvents") {
|
|
571
|
+
throw new Error("TODO");
|
|
572
|
+
} else if (message.tag === "ToClientKvResponse") {
|
|
573
|
+
const kvResponse = message.val;
|
|
574
|
+
this.#handleKvResponse(kvResponse);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
ws.addEventListener("error", (ev) => {
|
|
579
|
+
logger()?.error("WebSocket error:", ev.error);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
ws.addEventListener("close", (ev) => {
|
|
583
|
+
logger()?.info({
|
|
584
|
+
msg: "connection closed",
|
|
585
|
+
code: ev.code,
|
|
586
|
+
reason: ev.reason.toString(),
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
this.#config.onDisconnected();
|
|
590
|
+
|
|
591
|
+
// Clear ping loop on close
|
|
592
|
+
if (this.#pingLoop) {
|
|
593
|
+
clearInterval(this.#pingLoop);
|
|
594
|
+
this.#pingLoop = undefined;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Clear ack interval on close
|
|
598
|
+
if (this.#ackInterval) {
|
|
599
|
+
clearInterval(this.#ackInterval);
|
|
600
|
+
this.#ackInterval = undefined;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (!this.#shutdown) {
|
|
604
|
+
// Start runner lost timeout if we have a threshold and are not shutting down
|
|
605
|
+
if (
|
|
606
|
+
this.#runnerLostThreshold &&
|
|
607
|
+
this.#runnerLostThreshold > 0
|
|
608
|
+
) {
|
|
609
|
+
logger()?.info({
|
|
610
|
+
msg: "starting runner lost timeout",
|
|
611
|
+
seconds: this.#runnerLostThreshold / 1000,
|
|
612
|
+
});
|
|
613
|
+
this.#runnerLostTimeout = setTimeout(() => {
|
|
614
|
+
this.#stopAllActors();
|
|
615
|
+
}, this.#runnerLostThreshold);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Attempt to reconnect if not stopped
|
|
619
|
+
this.#scheduleReconnect();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
#handleCommands(commands: protocol.ToClientCommands) {
|
|
625
|
+
logger()?.info({
|
|
626
|
+
msg: "received commands",
|
|
627
|
+
commandCount: commands.length,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
for (const commandWrapper of commands) {
|
|
631
|
+
logger()?.info({ msg: "received command", commandWrapper });
|
|
632
|
+
if (commandWrapper.inner.tag === "CommandStartActor") {
|
|
633
|
+
this.#handleCommandStartActor(commandWrapper);
|
|
634
|
+
} else if (commandWrapper.inner.tag === "CommandStopActor") {
|
|
635
|
+
this.#handleCommandStopActor(commandWrapper);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
this.#lastCommandIdx = Number(commandWrapper.index);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
#handleCommandStartActor(commandWrapper: protocol.CommandWrapper) {
|
|
643
|
+
const startCommand = commandWrapper.inner
|
|
644
|
+
.val as protocol.CommandStartActor;
|
|
645
|
+
|
|
646
|
+
const actorId = startCommand.actorId;
|
|
647
|
+
const generation = startCommand.generation;
|
|
648
|
+
const config = startCommand.config;
|
|
649
|
+
|
|
650
|
+
const actorConfig: ActorConfig = {
|
|
651
|
+
name: config.name,
|
|
652
|
+
key: config.key,
|
|
653
|
+
createTs: config.createTs,
|
|
654
|
+
input: config.input ? new Uint8Array(config.input) : null,
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const instance: ActorInstance = {
|
|
658
|
+
actorId,
|
|
659
|
+
generation,
|
|
660
|
+
config: actorConfig,
|
|
661
|
+
requests: new Set(),
|
|
662
|
+
webSockets: new Set(),
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
this.#actors.set(actorId, instance);
|
|
666
|
+
|
|
667
|
+
this.#sendActorStateUpdate(actorId, generation, "running");
|
|
668
|
+
|
|
669
|
+
// TODO: Add timeout to onActorStart
|
|
670
|
+
// Call onActorStart asynchronously and handle errors
|
|
671
|
+
this.#config
|
|
672
|
+
.onActorStart(actorId, generation, actorConfig)
|
|
673
|
+
.catch((err) => {
|
|
674
|
+
logger()?.error({
|
|
675
|
+
msg: "error in onactorstart for actor",
|
|
676
|
+
actorId,
|
|
677
|
+
err,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// TODO: Mark as crashed
|
|
681
|
+
// Send stopped state update if start failed
|
|
682
|
+
this.stopActor(actorId, generation);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
#handleCommandStopActor(commandWrapper: protocol.CommandWrapper) {
|
|
687
|
+
const stopCommand = commandWrapper.inner
|
|
688
|
+
.val as protocol.CommandStopActor;
|
|
689
|
+
|
|
690
|
+
const actorId = stopCommand.actorId;
|
|
691
|
+
const generation = stopCommand.generation;
|
|
692
|
+
|
|
693
|
+
this.stopActor(actorId, generation);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
#sendActorIntent(
|
|
697
|
+
actorId: string,
|
|
698
|
+
generation: number,
|
|
699
|
+
intentType: "sleep" | "stop",
|
|
700
|
+
) {
|
|
701
|
+
if (this.#shutdown) {
|
|
702
|
+
logger()?.warn("Runner is shut down, cannot send actor intent");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
let actorIntent: protocol.ActorIntent;
|
|
706
|
+
|
|
707
|
+
if (intentType === "sleep") {
|
|
708
|
+
actorIntent = { tag: "ActorIntentSleep", val: null };
|
|
709
|
+
} else if (intentType === "stop") {
|
|
710
|
+
actorIntent = {
|
|
711
|
+
tag: "ActorIntentStop",
|
|
712
|
+
val: null,
|
|
713
|
+
};
|
|
714
|
+
} else {
|
|
715
|
+
unreachable(intentType);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const intentEvent: protocol.EventActorIntent = {
|
|
719
|
+
actorId,
|
|
720
|
+
generation,
|
|
721
|
+
intent: actorIntent,
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const eventIndex = this.#nextEventIdx++;
|
|
725
|
+
const eventWrapper: protocol.EventWrapper = {
|
|
726
|
+
index: eventIndex,
|
|
727
|
+
inner: {
|
|
728
|
+
tag: "EventActorIntent",
|
|
729
|
+
val: intentEvent,
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// Store event in history for potential resending
|
|
734
|
+
this.#eventHistory.push({
|
|
735
|
+
event: eventWrapper,
|
|
736
|
+
timestamp: Date.now(),
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
logger()?.info({
|
|
740
|
+
msg: "sending event to server",
|
|
741
|
+
index: eventWrapper.index,
|
|
742
|
+
tag: eventWrapper.inner.tag,
|
|
743
|
+
val: eventWrapper.inner.val,
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
this.#sendToServer({
|
|
747
|
+
tag: "ToServerEvents",
|
|
748
|
+
val: [eventWrapper],
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
#sendActorStateUpdate(
|
|
753
|
+
actorId: string,
|
|
754
|
+
generation: number,
|
|
755
|
+
stateType: "running" | "stopped",
|
|
756
|
+
) {
|
|
757
|
+
if (this.#shutdown) {
|
|
758
|
+
logger()?.warn(
|
|
759
|
+
"Runner is shut down, cannot send actor state update",
|
|
760
|
+
);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
let actorState: protocol.ActorState;
|
|
764
|
+
|
|
765
|
+
if (stateType === "running") {
|
|
766
|
+
actorState = { tag: "ActorStateRunning", val: null };
|
|
767
|
+
} else if (stateType === "stopped") {
|
|
768
|
+
actorState = {
|
|
769
|
+
tag: "ActorStateStopped",
|
|
770
|
+
val: {
|
|
771
|
+
code: protocol.StopCode.Ok,
|
|
772
|
+
message: "hello",
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
} else {
|
|
776
|
+
unreachable(stateType);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const stateUpdateEvent: protocol.EventActorStateUpdate = {
|
|
780
|
+
actorId,
|
|
781
|
+
generation,
|
|
782
|
+
state: actorState,
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const eventIndex = this.#nextEventIdx++;
|
|
786
|
+
const eventWrapper: protocol.EventWrapper = {
|
|
787
|
+
index: eventIndex,
|
|
788
|
+
inner: {
|
|
789
|
+
tag: "EventActorStateUpdate",
|
|
790
|
+
val: stateUpdateEvent,
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// Store event in history for potential resending
|
|
795
|
+
this.#eventHistory.push({
|
|
796
|
+
event: eventWrapper,
|
|
797
|
+
timestamp: Date.now(),
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
logger()?.info({
|
|
801
|
+
msg: "sending event to server",
|
|
802
|
+
index: eventWrapper.index,
|
|
803
|
+
tag: eventWrapper.inner.tag,
|
|
804
|
+
val: eventWrapper.inner.val,
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
this.#sendToServer({
|
|
808
|
+
tag: "ToServerEvents",
|
|
809
|
+
val: [eventWrapper],
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
#sendCommandAcknowledgment() {
|
|
814
|
+
if (this.#shutdown) {
|
|
815
|
+
logger()?.warn(
|
|
816
|
+
"Runner is shut down, cannot send command acknowledgment",
|
|
817
|
+
);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (this.#lastCommandIdx < 0) {
|
|
822
|
+
// No commands received yet, nothing to acknowledge
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
//logger()?.log("Sending command acknowledgment", this.#lastCommandIdx);
|
|
827
|
+
|
|
828
|
+
this.#sendToServer({
|
|
829
|
+
tag: "ToServerAckCommands",
|
|
830
|
+
val: {
|
|
831
|
+
lastCommandIdx: BigInt(this.#lastCommandIdx),
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
#handleKvResponse(response: protocol.ToClientKvResponse) {
|
|
837
|
+
const requestId = response.requestId;
|
|
838
|
+
const request = this.#kvRequests.get(requestId);
|
|
839
|
+
|
|
840
|
+
if (!request) {
|
|
841
|
+
const msg = "received kv response for unknown request id";
|
|
842
|
+
if (logger()) {
|
|
843
|
+
logger()?.error({ msg, requestId });
|
|
844
|
+
} else {
|
|
845
|
+
logger()?.error({ msg, requestId });
|
|
846
|
+
}
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
this.#kvRequests.delete(requestId);
|
|
851
|
+
|
|
852
|
+
if (response.data.tag === "KvErrorResponse") {
|
|
853
|
+
request.reject(
|
|
854
|
+
new Error(response.data.val.message || "Unknown KV error"),
|
|
855
|
+
);
|
|
856
|
+
} else {
|
|
857
|
+
request.resolve(response.data.val);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
#parseGetResponseSimple(
|
|
862
|
+
response: protocol.KvGetResponse,
|
|
863
|
+
requestedKeys: Uint8Array[],
|
|
864
|
+
): (Uint8Array | null)[] {
|
|
865
|
+
// Parse the response keys and values
|
|
866
|
+
const responseKeys: Uint8Array[] = [];
|
|
867
|
+
const responseValues: Uint8Array[] = [];
|
|
868
|
+
|
|
869
|
+
for (const key of response.keys) {
|
|
870
|
+
responseKeys.push(new Uint8Array(key));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
for (const value of response.values) {
|
|
874
|
+
responseValues.push(new Uint8Array(value));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Map response back to requested key order
|
|
878
|
+
const result: (Uint8Array | null)[] = [];
|
|
879
|
+
for (const requestedKey of requestedKeys) {
|
|
880
|
+
let found = false;
|
|
881
|
+
for (let i = 0; i < responseKeys.length; i++) {
|
|
882
|
+
if (this.#keysEqual(requestedKey, responseKeys[i])) {
|
|
883
|
+
result.push(responseValues[i]);
|
|
884
|
+
found = true;
|
|
885
|
+
break;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (!found) {
|
|
889
|
+
result.push(null);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
return result;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
#keysEqual(key1: Uint8Array, key2: Uint8Array): boolean {
|
|
897
|
+
if (key1.length !== key2.length) return false;
|
|
898
|
+
for (let i = 0; i < key1.length; i++) {
|
|
899
|
+
if (key1[i] !== key2[i]) return false;
|
|
900
|
+
}
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
//#parseGetResponse(response: protocol.KvGetResponse) {
|
|
905
|
+
// const keys: string[] = [];
|
|
906
|
+
// const values: Uint8Array[] = [];
|
|
907
|
+
// const metadata: { version: Uint8Array; createTs: bigint }[] = [];
|
|
908
|
+
//
|
|
909
|
+
// for (const key of response.keys) {
|
|
910
|
+
// keys.push(new TextDecoder().decode(key));
|
|
911
|
+
// }
|
|
912
|
+
//
|
|
913
|
+
// for (const value of response.values) {
|
|
914
|
+
// values.push(new Uint8Array(value));
|
|
915
|
+
// }
|
|
916
|
+
//
|
|
917
|
+
// for (const meta of response.metadata) {
|
|
918
|
+
// metadata.push({
|
|
919
|
+
// version: new Uint8Array(meta.version),
|
|
920
|
+
// createTs: meta.createTs,
|
|
921
|
+
// });
|
|
922
|
+
// }
|
|
923
|
+
//
|
|
924
|
+
// return { keys, values, metadata };
|
|
925
|
+
//}
|
|
926
|
+
|
|
927
|
+
#parseListResponseSimple(
|
|
928
|
+
response: protocol.KvListResponse,
|
|
929
|
+
): [Uint8Array, Uint8Array][] {
|
|
930
|
+
const result: [Uint8Array, Uint8Array][] = [];
|
|
931
|
+
|
|
932
|
+
for (let i = 0; i < response.keys.length; i++) {
|
|
933
|
+
const key = response.keys[i];
|
|
934
|
+
const value = response.values[i];
|
|
935
|
+
|
|
936
|
+
if (key && value) {
|
|
937
|
+
const keyBytes = new Uint8Array(key);
|
|
938
|
+
const valueBytes = new Uint8Array(value);
|
|
939
|
+
result.push([keyBytes, valueBytes]);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return result;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
//#parseListResponse(response: protocol.KvListResponse) {
|
|
947
|
+
// const keys: string[] = [];
|
|
948
|
+
// const values: Uint8Array[] = [];
|
|
949
|
+
// const metadata: { version: Uint8Array; createTs: bigint }[] = [];
|
|
950
|
+
//
|
|
951
|
+
// for (const key of response.keys) {
|
|
952
|
+
// keys.push(new TextDecoder().decode(key));
|
|
953
|
+
// }
|
|
954
|
+
//
|
|
955
|
+
// for (const value of response.values) {
|
|
956
|
+
// values.push(new Uint8Array(value));
|
|
957
|
+
// }
|
|
958
|
+
//
|
|
959
|
+
// for (const meta of response.metadata) {
|
|
960
|
+
// metadata.push({
|
|
961
|
+
// version: new Uint8Array(meta.version),
|
|
962
|
+
// createTs: meta.createTs,
|
|
963
|
+
// });
|
|
964
|
+
// }
|
|
965
|
+
//
|
|
966
|
+
// return { keys, values, metadata };
|
|
967
|
+
//}
|
|
968
|
+
|
|
969
|
+
// MARK: KV Operations
|
|
970
|
+
async kvGet(
|
|
971
|
+
actorId: string,
|
|
972
|
+
keys: Uint8Array[],
|
|
973
|
+
): Promise<(Uint8Array | null)[]> {
|
|
974
|
+
const kvKeys: protocol.KvKey[] = keys.map(
|
|
975
|
+
(key) =>
|
|
976
|
+
key.buffer.slice(
|
|
977
|
+
key.byteOffset,
|
|
978
|
+
key.byteOffset + key.byteLength,
|
|
979
|
+
) as ArrayBuffer,
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
const requestData: protocol.KvRequestData = {
|
|
983
|
+
tag: "KvGetRequest",
|
|
984
|
+
val: { keys: kvKeys },
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const response = await this.#sendKvRequest(actorId, requestData);
|
|
988
|
+
return this.#parseGetResponseSimple(response, keys);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async kvListAll(
|
|
992
|
+
actorId: string,
|
|
993
|
+
options?: KvListOptions,
|
|
994
|
+
): Promise<[Uint8Array, Uint8Array][]> {
|
|
995
|
+
const requestData: protocol.KvRequestData = {
|
|
996
|
+
tag: "KvListRequest",
|
|
997
|
+
val: {
|
|
998
|
+
query: { tag: "KvListAllQuery", val: null },
|
|
999
|
+
reverse: options?.reverse || null,
|
|
1000
|
+
limit:
|
|
1001
|
+
options?.limit !== undefined ? BigInt(options.limit) : null,
|
|
1002
|
+
},
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const response = await this.#sendKvRequest(actorId, requestData);
|
|
1006
|
+
return this.#parseListResponseSimple(response);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async kvListRange(
|
|
1010
|
+
actorId: string,
|
|
1011
|
+
start: Uint8Array,
|
|
1012
|
+
end: Uint8Array,
|
|
1013
|
+
exclusive?: boolean,
|
|
1014
|
+
options?: KvListOptions,
|
|
1015
|
+
): Promise<[Uint8Array, Uint8Array][]> {
|
|
1016
|
+
const startKey: protocol.KvKey = start.buffer.slice(
|
|
1017
|
+
start.byteOffset,
|
|
1018
|
+
start.byteOffset + start.byteLength,
|
|
1019
|
+
) as ArrayBuffer;
|
|
1020
|
+
const endKey: protocol.KvKey = end.buffer.slice(
|
|
1021
|
+
end.byteOffset,
|
|
1022
|
+
end.byteOffset + end.byteLength,
|
|
1023
|
+
) as ArrayBuffer;
|
|
1024
|
+
|
|
1025
|
+
const requestData: protocol.KvRequestData = {
|
|
1026
|
+
tag: "KvListRequest",
|
|
1027
|
+
val: {
|
|
1028
|
+
query: {
|
|
1029
|
+
tag: "KvListRangeQuery",
|
|
1030
|
+
val: {
|
|
1031
|
+
start: startKey,
|
|
1032
|
+
end: endKey,
|
|
1033
|
+
exclusive: exclusive || false,
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
reverse: options?.reverse || null,
|
|
1037
|
+
limit:
|
|
1038
|
+
options?.limit !== undefined ? BigInt(options.limit) : null,
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const response = await this.#sendKvRequest(actorId, requestData);
|
|
1043
|
+
return this.#parseListResponseSimple(response);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async kvListPrefix(
|
|
1047
|
+
actorId: string,
|
|
1048
|
+
prefix: Uint8Array,
|
|
1049
|
+
options?: KvListOptions,
|
|
1050
|
+
): Promise<[Uint8Array, Uint8Array][]> {
|
|
1051
|
+
const prefixKey: protocol.KvKey = prefix.buffer.slice(
|
|
1052
|
+
prefix.byteOffset,
|
|
1053
|
+
prefix.byteOffset + prefix.byteLength,
|
|
1054
|
+
) as ArrayBuffer;
|
|
1055
|
+
|
|
1056
|
+
const requestData: protocol.KvRequestData = {
|
|
1057
|
+
tag: "KvListRequest",
|
|
1058
|
+
val: {
|
|
1059
|
+
query: {
|
|
1060
|
+
tag: "KvListPrefixQuery",
|
|
1061
|
+
val: { key: prefixKey },
|
|
1062
|
+
},
|
|
1063
|
+
reverse: options?.reverse || null,
|
|
1064
|
+
limit:
|
|
1065
|
+
options?.limit !== undefined ? BigInt(options.limit) : null,
|
|
1066
|
+
},
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
const response = await this.#sendKvRequest(actorId, requestData);
|
|
1070
|
+
return this.#parseListResponseSimple(response);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async kvPut(
|
|
1074
|
+
actorId: string,
|
|
1075
|
+
entries: [Uint8Array, Uint8Array][],
|
|
1076
|
+
): Promise<void> {
|
|
1077
|
+
const keys: protocol.KvKey[] = entries.map(
|
|
1078
|
+
([key, _value]) =>
|
|
1079
|
+
key.buffer.slice(
|
|
1080
|
+
key.byteOffset,
|
|
1081
|
+
key.byteOffset + key.byteLength,
|
|
1082
|
+
) as ArrayBuffer,
|
|
1083
|
+
);
|
|
1084
|
+
const values: protocol.KvValue[] = entries.map(
|
|
1085
|
+
([_key, value]) =>
|
|
1086
|
+
value.buffer.slice(
|
|
1087
|
+
value.byteOffset,
|
|
1088
|
+
value.byteOffset + value.byteLength,
|
|
1089
|
+
) as ArrayBuffer,
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
const requestData: protocol.KvRequestData = {
|
|
1093
|
+
tag: "KvPutRequest",
|
|
1094
|
+
val: { keys, values },
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
await this.#sendKvRequest(actorId, requestData);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async kvDelete(actorId: string, keys: Uint8Array[]): Promise<void> {
|
|
1101
|
+
const kvKeys: protocol.KvKey[] = keys.map(
|
|
1102
|
+
(key) =>
|
|
1103
|
+
key.buffer.slice(
|
|
1104
|
+
key.byteOffset,
|
|
1105
|
+
key.byteOffset + key.byteLength,
|
|
1106
|
+
) as ArrayBuffer,
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
const requestData: protocol.KvRequestData = {
|
|
1110
|
+
tag: "KvDeleteRequest",
|
|
1111
|
+
val: { keys: kvKeys },
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
await this.#sendKvRequest(actorId, requestData);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async kvDrop(actorId: string): Promise<void> {
|
|
1118
|
+
const requestData: protocol.KvRequestData = {
|
|
1119
|
+
tag: "KvDropRequest",
|
|
1120
|
+
val: null,
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
await this.#sendKvRequest(actorId, requestData);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// MARK: Alarm Operations
|
|
1127
|
+
setAlarm(actorId: string, alarmTs: number | null, generation?: number) {
|
|
1128
|
+
const actor = this.getActor(actorId, generation);
|
|
1129
|
+
if (!actor) return;
|
|
1130
|
+
|
|
1131
|
+
if (this.#shutdown) {
|
|
1132
|
+
console.warn("Runner is shut down, cannot set alarm");
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const alarmEvent: protocol.EventActorSetAlarm = {
|
|
1137
|
+
actorId,
|
|
1138
|
+
generation: actor.generation,
|
|
1139
|
+
alarmTs: alarmTs !== null ? BigInt(alarmTs) : null,
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const eventIndex = this.#nextEventIdx++;
|
|
1143
|
+
const eventWrapper: protocol.EventWrapper = {
|
|
1144
|
+
index: eventIndex,
|
|
1145
|
+
inner: {
|
|
1146
|
+
tag: "EventActorSetAlarm",
|
|
1147
|
+
val: alarmEvent,
|
|
1148
|
+
},
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
// Store event in history for potential resending
|
|
1152
|
+
this.#eventHistory.push({
|
|
1153
|
+
event: eventWrapper,
|
|
1154
|
+
timestamp: Date.now(),
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
this.#sendToServer({
|
|
1158
|
+
tag: "ToServerEvents",
|
|
1159
|
+
val: [eventWrapper],
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
clearAlarm(actorId: string, generation?: number) {
|
|
1164
|
+
this.setAlarm(actorId, null, generation);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
#sendKvRequest(
|
|
1168
|
+
actorId: string,
|
|
1169
|
+
requestData: protocol.KvRequestData,
|
|
1170
|
+
): Promise<any> {
|
|
1171
|
+
return new Promise((resolve, reject) => {
|
|
1172
|
+
if (this.#shutdown) {
|
|
1173
|
+
reject(new Error("Runner is shut down"));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
const requestId = this.#nextRequestId++;
|
|
1178
|
+
const isConnected =
|
|
1179
|
+
this.#pegboardWebSocket &&
|
|
1180
|
+
this.#pegboardWebSocket.readyState === WebSocket.OPEN;
|
|
1181
|
+
|
|
1182
|
+
// Store the request
|
|
1183
|
+
const requestEntry = {
|
|
1184
|
+
actorId,
|
|
1185
|
+
data: requestData,
|
|
1186
|
+
resolve,
|
|
1187
|
+
reject,
|
|
1188
|
+
sent: false,
|
|
1189
|
+
timestamp: Date.now(),
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
this.#kvRequests.set(requestId, requestEntry);
|
|
1193
|
+
|
|
1194
|
+
if (isConnected) {
|
|
1195
|
+
// Send immediately
|
|
1196
|
+
this.#sendSingleKvRequest(requestId);
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
#sendSingleKvRequest(requestId: number) {
|
|
1202
|
+
const request = this.#kvRequests.get(requestId);
|
|
1203
|
+
if (!request || request.sent) return;
|
|
1204
|
+
|
|
1205
|
+
try {
|
|
1206
|
+
const kvRequest: protocol.ToServerKvRequest = {
|
|
1207
|
+
actorId: request.actorId,
|
|
1208
|
+
requestId,
|
|
1209
|
+
data: request.data,
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
this.#sendToServer({
|
|
1213
|
+
tag: "ToServerKvRequest",
|
|
1214
|
+
val: kvRequest,
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// Mark as sent and update timestamp
|
|
1218
|
+
request.sent = true;
|
|
1219
|
+
request.timestamp = Date.now();
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
this.#kvRequests.delete(requestId);
|
|
1222
|
+
request.reject(error);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
#processUnsentKvRequests() {
|
|
1227
|
+
if (
|
|
1228
|
+
!this.#pegboardWebSocket ||
|
|
1229
|
+
this.#pegboardWebSocket.readyState !== WebSocket.OPEN
|
|
1230
|
+
) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
let processedCount = 0;
|
|
1235
|
+
for (const [requestId, request] of this.#kvRequests.entries()) {
|
|
1236
|
+
if (!request.sent) {
|
|
1237
|
+
this.#sendSingleKvRequest(requestId);
|
|
1238
|
+
processedCount++;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (processedCount > 0) {
|
|
1243
|
+
//logger()?.log(`Processed ${processedCount} queued KV requests`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
#sendToServer(message: protocol.ToServer) {
|
|
1248
|
+
if (this.#shutdown) {
|
|
1249
|
+
logger()?.warn(
|
|
1250
|
+
"Runner is shut down, cannot send message to server",
|
|
1251
|
+
);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const encoded = protocol.encodeToServer(message);
|
|
1256
|
+
if (
|
|
1257
|
+
this.#pegboardWebSocket &&
|
|
1258
|
+
this.#pegboardWebSocket.readyState === WebSocket.OPEN
|
|
1259
|
+
) {
|
|
1260
|
+
this.#pegboardWebSocket.send(encoded);
|
|
1261
|
+
} else {
|
|
1262
|
+
logger()?.error(
|
|
1263
|
+
"WebSocket not available or not open for sending data",
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
#scheduleReconnect() {
|
|
1269
|
+
if (this.#shutdown) {
|
|
1270
|
+
//logger()?.log("Runner is shut down, not attempting reconnect");
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const delay = calculateBackoff(this.#reconnectAttempt, {
|
|
1275
|
+
initialDelay: 1000,
|
|
1276
|
+
maxDelay: 30000,
|
|
1277
|
+
multiplier: 2,
|
|
1278
|
+
jitter: true,
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
//logger()?.log(
|
|
1282
|
+
// `Scheduling reconnect attempt ${this.#reconnectAttempt + 1} in ${delay}ms`,
|
|
1283
|
+
//);
|
|
1284
|
+
|
|
1285
|
+
this.#reconnectTimeout = setTimeout(async () => {
|
|
1286
|
+
if (!this.#shutdown) {
|
|
1287
|
+
this.#reconnectAttempt++;
|
|
1288
|
+
//logger()?.log(
|
|
1289
|
+
// `Attempting to reconnect (attempt ${this.#reconnectAttempt})...`,
|
|
1290
|
+
//);
|
|
1291
|
+
await this.#openPegboardWebSocket();
|
|
1292
|
+
}
|
|
1293
|
+
}, delay);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
#resendUnacknowledgedEvents(lastEventIdx: bigint) {
|
|
1297
|
+
const eventsToResend = this.#eventHistory.filter(
|
|
1298
|
+
(item) => item.event.index > lastEventIdx,
|
|
1299
|
+
);
|
|
1300
|
+
|
|
1301
|
+
if (eventsToResend.length === 0) return;
|
|
1302
|
+
|
|
1303
|
+
//logger()?.log(
|
|
1304
|
+
// `Resending ${eventsToResend.length} unacknowledged events from index ${Number(lastEventIdx) + 1}`,
|
|
1305
|
+
//);
|
|
1306
|
+
|
|
1307
|
+
// Resend events in batches
|
|
1308
|
+
const events = eventsToResend.map((item) => item.event);
|
|
1309
|
+
this.#sendToServer({
|
|
1310
|
+
tag: "ToServerEvents",
|
|
1311
|
+
val: events,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// TODO(RVT-4986): Prune when server acks events instead of based on old events
|
|
1316
|
+
#pruneOldEvents() {
|
|
1317
|
+
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
|
1318
|
+
const originalLength = this.#eventHistory.length;
|
|
1319
|
+
|
|
1320
|
+
// Remove events older than 5 minutes
|
|
1321
|
+
this.#eventHistory = this.#eventHistory.filter(
|
|
1322
|
+
(item) => item.timestamp > fiveMinutesAgo,
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
const prunedCount = originalLength - this.#eventHistory.length;
|
|
1326
|
+
if (prunedCount > 0) {
|
|
1327
|
+
//logger()?.log(`Pruned ${prunedCount} old events from history`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
#cleanupOldKvRequests() {
|
|
1332
|
+
const thirtySecondsAgo = Date.now() - KV_EXPIRE;
|
|
1333
|
+
const toDelete: number[] = [];
|
|
1334
|
+
|
|
1335
|
+
for (const [requestId, request] of this.#kvRequests.entries()) {
|
|
1336
|
+
if (request.timestamp < thirtySecondsAgo) {
|
|
1337
|
+
request.reject(
|
|
1338
|
+
new Error(
|
|
1339
|
+
"KV request timed out waiting for WebSocket connection",
|
|
1340
|
+
),
|
|
1341
|
+
);
|
|
1342
|
+
toDelete.push(requestId);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
for (const requestId of toDelete) {
|
|
1347
|
+
this.#kvRequests.delete(requestId);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
if (toDelete.length > 0) {
|
|
1351
|
+
//logger()?.log(`Cleaned up ${toDelete.length} expired KV requests`);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|