@topgunbuild/client 0.2.0 → 0.3.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/dist/index.d.mts +840 -10
- package/dist/index.d.ts +840 -10
- package/dist/index.js +2258 -187
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2257 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -203,6 +203,233 @@ var DEFAULT_BACKPRESSURE_CONFIG = {
|
|
|
203
203
|
lowWaterMark: 0.5
|
|
204
204
|
};
|
|
205
205
|
|
|
206
|
+
// src/connection/SingleServerProvider.ts
|
|
207
|
+
var DEFAULT_CONFIG = {
|
|
208
|
+
maxReconnectAttempts: 10,
|
|
209
|
+
reconnectDelayMs: 1e3,
|
|
210
|
+
backoffMultiplier: 2,
|
|
211
|
+
maxReconnectDelayMs: 3e4
|
|
212
|
+
};
|
|
213
|
+
var SingleServerProvider = class {
|
|
214
|
+
constructor(config) {
|
|
215
|
+
this.ws = null;
|
|
216
|
+
this.reconnectAttempts = 0;
|
|
217
|
+
this.reconnectTimer = null;
|
|
218
|
+
this.isClosing = false;
|
|
219
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
220
|
+
this.url = config.url;
|
|
221
|
+
this.config = {
|
|
222
|
+
url: config.url,
|
|
223
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
|
|
224
|
+
reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
|
|
225
|
+
backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
|
|
226
|
+
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Connect to the WebSocket server.
|
|
231
|
+
*/
|
|
232
|
+
async connect() {
|
|
233
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.isClosing = false;
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
try {
|
|
239
|
+
this.ws = new WebSocket(this.url);
|
|
240
|
+
this.ws.binaryType = "arraybuffer";
|
|
241
|
+
this.ws.onopen = () => {
|
|
242
|
+
this.reconnectAttempts = 0;
|
|
243
|
+
logger.info({ url: this.url }, "SingleServerProvider connected");
|
|
244
|
+
this.emit("connected", "default");
|
|
245
|
+
resolve();
|
|
246
|
+
};
|
|
247
|
+
this.ws.onerror = (error) => {
|
|
248
|
+
logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
|
|
249
|
+
this.emit("error", error);
|
|
250
|
+
};
|
|
251
|
+
this.ws.onclose = (event) => {
|
|
252
|
+
logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
|
|
253
|
+
this.emit("disconnected", "default");
|
|
254
|
+
if (!this.isClosing) {
|
|
255
|
+
this.scheduleReconnect();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
this.ws.onmessage = (event) => {
|
|
259
|
+
this.emit("message", "default", event.data);
|
|
260
|
+
};
|
|
261
|
+
const timeoutId = setTimeout(() => {
|
|
262
|
+
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
263
|
+
this.ws.close();
|
|
264
|
+
reject(new Error(`Connection timeout to ${this.url}`));
|
|
265
|
+
}
|
|
266
|
+
}, this.config.reconnectDelayMs * 5);
|
|
267
|
+
const originalOnOpen = this.ws.onopen;
|
|
268
|
+
const wsRef = this.ws;
|
|
269
|
+
this.ws.onopen = (ev) => {
|
|
270
|
+
clearTimeout(timeoutId);
|
|
271
|
+
if (originalOnOpen) {
|
|
272
|
+
originalOnOpen.call(wsRef, ev);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
} catch (error) {
|
|
276
|
+
reject(error);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Get connection for a specific key.
|
|
282
|
+
* In single-server mode, key is ignored.
|
|
283
|
+
*/
|
|
284
|
+
getConnection(_key) {
|
|
285
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
286
|
+
throw new Error("Not connected");
|
|
287
|
+
}
|
|
288
|
+
return this.ws;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Get any available connection.
|
|
292
|
+
*/
|
|
293
|
+
getAnyConnection() {
|
|
294
|
+
return this.getConnection("");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Check if connected.
|
|
298
|
+
*/
|
|
299
|
+
isConnected() {
|
|
300
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Get connected node IDs.
|
|
304
|
+
* Single-server mode returns ['default'] when connected.
|
|
305
|
+
*/
|
|
306
|
+
getConnectedNodes() {
|
|
307
|
+
return this.isConnected() ? ["default"] : [];
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Subscribe to connection events.
|
|
311
|
+
*/
|
|
312
|
+
on(event, handler2) {
|
|
313
|
+
if (!this.listeners.has(event)) {
|
|
314
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
315
|
+
}
|
|
316
|
+
this.listeners.get(event).add(handler2);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Unsubscribe from connection events.
|
|
320
|
+
*/
|
|
321
|
+
off(event, handler2) {
|
|
322
|
+
this.listeners.get(event)?.delete(handler2);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Send data via the WebSocket connection.
|
|
326
|
+
* In single-server mode, key parameter is ignored.
|
|
327
|
+
*/
|
|
328
|
+
send(data, _key) {
|
|
329
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
330
|
+
throw new Error("Not connected");
|
|
331
|
+
}
|
|
332
|
+
this.ws.send(data);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Close the WebSocket connection.
|
|
336
|
+
*/
|
|
337
|
+
async close() {
|
|
338
|
+
this.isClosing = true;
|
|
339
|
+
if (this.reconnectTimer) {
|
|
340
|
+
clearTimeout(this.reconnectTimer);
|
|
341
|
+
this.reconnectTimer = null;
|
|
342
|
+
}
|
|
343
|
+
if (this.ws) {
|
|
344
|
+
this.ws.onclose = null;
|
|
345
|
+
this.ws.onerror = null;
|
|
346
|
+
this.ws.onmessage = null;
|
|
347
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
348
|
+
this.ws.close();
|
|
349
|
+
}
|
|
350
|
+
this.ws = null;
|
|
351
|
+
}
|
|
352
|
+
logger.info({ url: this.url }, "SingleServerProvider closed");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Emit an event to all listeners.
|
|
356
|
+
*/
|
|
357
|
+
emit(event, ...args) {
|
|
358
|
+
const handlers = this.listeners.get(event);
|
|
359
|
+
if (handlers) {
|
|
360
|
+
for (const handler2 of handlers) {
|
|
361
|
+
try {
|
|
362
|
+
handler2(...args);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
logger.error({ err, event }, "Error in SingleServerProvider event handler");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
371
|
+
*/
|
|
372
|
+
scheduleReconnect() {
|
|
373
|
+
if (this.reconnectTimer) {
|
|
374
|
+
clearTimeout(this.reconnectTimer);
|
|
375
|
+
this.reconnectTimer = null;
|
|
376
|
+
}
|
|
377
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
378
|
+
logger.error(
|
|
379
|
+
{ attempts: this.reconnectAttempts, url: this.url },
|
|
380
|
+
"SingleServerProvider max reconnect attempts reached"
|
|
381
|
+
);
|
|
382
|
+
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const delay = this.calculateBackoffDelay();
|
|
386
|
+
logger.info(
|
|
387
|
+
{ delay, attempt: this.reconnectAttempts, url: this.url },
|
|
388
|
+
`SingleServerProvider scheduling reconnect in ${delay}ms`
|
|
389
|
+
);
|
|
390
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
391
|
+
this.reconnectTimer = null;
|
|
392
|
+
this.reconnectAttempts++;
|
|
393
|
+
try {
|
|
394
|
+
await this.connect();
|
|
395
|
+
this.emit("reconnected", "default");
|
|
396
|
+
} catch (error) {
|
|
397
|
+
logger.error({ err: error }, "SingleServerProvider reconnection failed");
|
|
398
|
+
this.scheduleReconnect();
|
|
399
|
+
}
|
|
400
|
+
}, delay);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Calculate backoff delay with exponential increase.
|
|
404
|
+
*/
|
|
405
|
+
calculateBackoffDelay() {
|
|
406
|
+
const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
|
|
407
|
+
let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
|
|
408
|
+
delay = Math.min(delay, maxReconnectDelayMs);
|
|
409
|
+
delay = delay * (0.5 + Math.random());
|
|
410
|
+
return Math.floor(delay);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Get the WebSocket URL this provider connects to.
|
|
414
|
+
*/
|
|
415
|
+
getUrl() {
|
|
416
|
+
return this.url;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Get current reconnection attempt count.
|
|
420
|
+
*/
|
|
421
|
+
getReconnectAttempts() {
|
|
422
|
+
return this.reconnectAttempts;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Reset reconnection counter.
|
|
426
|
+
* Called externally after successful authentication.
|
|
427
|
+
*/
|
|
428
|
+
resetReconnectAttempts() {
|
|
429
|
+
this.reconnectAttempts = 0;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
206
433
|
// src/SyncEngine.ts
|
|
207
434
|
var DEFAULT_BACKOFF_CONFIG = {
|
|
208
435
|
initialDelayMs: 1e3,
|
|
@@ -232,8 +459,13 @@ var SyncEngine = class {
|
|
|
232
459
|
this.waitingForCapacity = [];
|
|
233
460
|
this.highWaterMarkEmitted = false;
|
|
234
461
|
this.backpressureListeners = /* @__PURE__ */ new Map();
|
|
462
|
+
// Write Concern state (Phase 5.01)
|
|
463
|
+
this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
|
|
464
|
+
if (!config.serverUrl && !config.connectionProvider) {
|
|
465
|
+
throw new Error("SyncEngine requires either serverUrl or connectionProvider");
|
|
466
|
+
}
|
|
235
467
|
this.nodeId = config.nodeId;
|
|
236
|
-
this.serverUrl = config.serverUrl;
|
|
468
|
+
this.serverUrl = config.serverUrl || "";
|
|
237
469
|
this.storageAdapter = config.storageAdapter;
|
|
238
470
|
this.hlc = new HLC(this.nodeId);
|
|
239
471
|
this.stateMachine = new SyncStateMachine();
|
|
@@ -250,7 +482,15 @@ var SyncEngine = class {
|
|
|
250
482
|
...DEFAULT_BACKPRESSURE_CONFIG,
|
|
251
483
|
...config.backpressure
|
|
252
484
|
};
|
|
253
|
-
|
|
485
|
+
if (config.connectionProvider) {
|
|
486
|
+
this.connectionProvider = config.connectionProvider;
|
|
487
|
+
this.useConnectionProvider = true;
|
|
488
|
+
this.initConnectionProvider();
|
|
489
|
+
} else {
|
|
490
|
+
this.connectionProvider = new SingleServerProvider({ url: config.serverUrl });
|
|
491
|
+
this.useConnectionProvider = false;
|
|
492
|
+
this.initConnection();
|
|
493
|
+
}
|
|
254
494
|
this.loadOpLog();
|
|
255
495
|
}
|
|
256
496
|
// ============================================
|
|
@@ -301,6 +541,65 @@ var SyncEngine = class {
|
|
|
301
541
|
// ============================================
|
|
302
542
|
// Connection Management
|
|
303
543
|
// ============================================
|
|
544
|
+
/**
|
|
545
|
+
* Initialize connection using IConnectionProvider (Phase 4.5 cluster mode).
|
|
546
|
+
* Sets up event handlers for the connection provider.
|
|
547
|
+
*/
|
|
548
|
+
initConnectionProvider() {
|
|
549
|
+
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
550
|
+
this.connectionProvider.on("connected", (_nodeId) => {
|
|
551
|
+
if (this.authToken || this.tokenProvider) {
|
|
552
|
+
logger.info("ConnectionProvider connected. Sending auth...");
|
|
553
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
554
|
+
this.sendAuth();
|
|
555
|
+
} else {
|
|
556
|
+
logger.info("ConnectionProvider connected. Waiting for auth token...");
|
|
557
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
this.connectionProvider.on("disconnected", (_nodeId) => {
|
|
561
|
+
logger.info("ConnectionProvider disconnected.");
|
|
562
|
+
this.stopHeartbeat();
|
|
563
|
+
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
564
|
+
});
|
|
565
|
+
this.connectionProvider.on("reconnected", (_nodeId) => {
|
|
566
|
+
logger.info("ConnectionProvider reconnected.");
|
|
567
|
+
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
568
|
+
if (this.authToken || this.tokenProvider) {
|
|
569
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
570
|
+
this.sendAuth();
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
this.connectionProvider.on("message", (_nodeId, data) => {
|
|
574
|
+
let message;
|
|
575
|
+
if (data instanceof ArrayBuffer) {
|
|
576
|
+
message = deserialize(new Uint8Array(data));
|
|
577
|
+
} else if (data instanceof Uint8Array) {
|
|
578
|
+
message = deserialize(data);
|
|
579
|
+
} else {
|
|
580
|
+
try {
|
|
581
|
+
message = typeof data === "string" ? JSON.parse(data) : data;
|
|
582
|
+
} catch (e) {
|
|
583
|
+
logger.error({ err: e }, "Failed to parse message from ConnectionProvider");
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
this.handleServerMessage(message);
|
|
588
|
+
});
|
|
589
|
+
this.connectionProvider.on("partitionMapUpdated", () => {
|
|
590
|
+
logger.debug("Partition map updated");
|
|
591
|
+
});
|
|
592
|
+
this.connectionProvider.on("error", (error) => {
|
|
593
|
+
logger.error({ err: error }, "ConnectionProvider error");
|
|
594
|
+
});
|
|
595
|
+
this.connectionProvider.connect().catch((err) => {
|
|
596
|
+
logger.error({ err }, "Failed to connect via ConnectionProvider");
|
|
597
|
+
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Initialize connection using direct WebSocket (legacy single-server mode).
|
|
602
|
+
*/
|
|
304
603
|
initConnection() {
|
|
305
604
|
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
306
605
|
this.websocket = new WebSocket(this.serverUrl);
|
|
@@ -376,6 +675,40 @@ var SyncEngine = class {
|
|
|
376
675
|
resetBackoff() {
|
|
377
676
|
this.backoffAttempt = 0;
|
|
378
677
|
}
|
|
678
|
+
/**
|
|
679
|
+
* Send a message through the current connection.
|
|
680
|
+
* Uses connectionProvider if in cluster mode, otherwise uses direct websocket.
|
|
681
|
+
* @param message Message object to serialize and send
|
|
682
|
+
* @param key Optional key for routing (cluster mode only)
|
|
683
|
+
* @returns true if message was sent, false otherwise
|
|
684
|
+
*/
|
|
685
|
+
sendMessage(message, key) {
|
|
686
|
+
const data = serialize(message);
|
|
687
|
+
if (this.useConnectionProvider) {
|
|
688
|
+
try {
|
|
689
|
+
this.connectionProvider.send(data, key);
|
|
690
|
+
return true;
|
|
691
|
+
} catch (err) {
|
|
692
|
+
logger.warn({ err }, "Failed to send via ConnectionProvider");
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
if (this.websocket?.readyState === WebSocket.OPEN) {
|
|
697
|
+
this.websocket.send(data);
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Check if we can send messages (connection is ready).
|
|
705
|
+
*/
|
|
706
|
+
canSend() {
|
|
707
|
+
if (this.useConnectionProvider) {
|
|
708
|
+
return this.connectionProvider.isConnected();
|
|
709
|
+
}
|
|
710
|
+
return this.websocket?.readyState === WebSocket.OPEN;
|
|
711
|
+
}
|
|
379
712
|
async loadOpLog() {
|
|
380
713
|
const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
|
|
381
714
|
if (storedTimestamp) {
|
|
@@ -422,36 +755,34 @@ var SyncEngine = class {
|
|
|
422
755
|
const pending = this.opLog.filter((op) => !op.synced);
|
|
423
756
|
if (pending.length === 0) return;
|
|
424
757
|
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}));
|
|
432
|
-
}
|
|
758
|
+
this.sendMessage({
|
|
759
|
+
type: "OP_BATCH",
|
|
760
|
+
payload: {
|
|
761
|
+
ops: pending
|
|
762
|
+
}
|
|
763
|
+
});
|
|
433
764
|
}
|
|
434
765
|
startMerkleSync() {
|
|
435
766
|
for (const [mapName, map] of this.maps) {
|
|
436
767
|
if (map instanceof LWWMap) {
|
|
437
768
|
logger.info({ mapName }, "Starting Merkle sync for LWWMap");
|
|
438
|
-
this.
|
|
769
|
+
this.sendMessage({
|
|
439
770
|
type: "SYNC_INIT",
|
|
440
771
|
mapName,
|
|
441
772
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
442
|
-
})
|
|
773
|
+
});
|
|
443
774
|
} else if (map instanceof ORMap) {
|
|
444
775
|
logger.info({ mapName }, "Starting Merkle sync for ORMap");
|
|
445
776
|
const tree = map.getMerkleTree();
|
|
446
777
|
const rootHash = tree.getRootHash();
|
|
447
778
|
const bucketHashes = tree.getBuckets("");
|
|
448
|
-
this.
|
|
779
|
+
this.sendMessage({
|
|
449
780
|
type: "ORMAP_SYNC_INIT",
|
|
450
781
|
mapName,
|
|
451
782
|
rootHash,
|
|
452
783
|
bucketHashes,
|
|
453
784
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
454
|
-
})
|
|
785
|
+
});
|
|
455
786
|
}
|
|
456
787
|
}
|
|
457
788
|
}
|
|
@@ -492,10 +823,10 @@ var SyncEngine = class {
|
|
|
492
823
|
}
|
|
493
824
|
const token = this.authToken;
|
|
494
825
|
if (!token) return;
|
|
495
|
-
this.
|
|
826
|
+
this.sendMessage({
|
|
496
827
|
type: "AUTH",
|
|
497
828
|
token
|
|
498
|
-
})
|
|
829
|
+
});
|
|
499
830
|
}
|
|
500
831
|
subscribeToQuery(query) {
|
|
501
832
|
this.queries.set(query.id, query);
|
|
@@ -512,27 +843,27 @@ var SyncEngine = class {
|
|
|
512
843
|
unsubscribeFromTopic(topic) {
|
|
513
844
|
this.topics.delete(topic);
|
|
514
845
|
if (this.isAuthenticated()) {
|
|
515
|
-
this.
|
|
846
|
+
this.sendMessage({
|
|
516
847
|
type: "TOPIC_UNSUB",
|
|
517
848
|
payload: { topic }
|
|
518
|
-
})
|
|
849
|
+
});
|
|
519
850
|
}
|
|
520
851
|
}
|
|
521
852
|
publishTopic(topic, data) {
|
|
522
853
|
if (this.isAuthenticated()) {
|
|
523
|
-
this.
|
|
854
|
+
this.sendMessage({
|
|
524
855
|
type: "TOPIC_PUB",
|
|
525
856
|
payload: { topic, data }
|
|
526
|
-
})
|
|
857
|
+
});
|
|
527
858
|
} else {
|
|
528
859
|
logger.warn({ topic }, "Dropped topic publish (offline)");
|
|
529
860
|
}
|
|
530
861
|
}
|
|
531
862
|
sendTopicSubscription(topic) {
|
|
532
|
-
this.
|
|
863
|
+
this.sendMessage({
|
|
533
864
|
type: "TOPIC_SUB",
|
|
534
865
|
payload: { topic }
|
|
535
|
-
})
|
|
866
|
+
});
|
|
536
867
|
}
|
|
537
868
|
/**
|
|
538
869
|
* Executes a query against local storage immediately
|
|
@@ -569,21 +900,21 @@ var SyncEngine = class {
|
|
|
569
900
|
unsubscribeFromQuery(queryId) {
|
|
570
901
|
this.queries.delete(queryId);
|
|
571
902
|
if (this.isAuthenticated()) {
|
|
572
|
-
this.
|
|
903
|
+
this.sendMessage({
|
|
573
904
|
type: "QUERY_UNSUB",
|
|
574
905
|
payload: { queryId }
|
|
575
|
-
})
|
|
906
|
+
});
|
|
576
907
|
}
|
|
577
908
|
}
|
|
578
909
|
sendQuerySubscription(query) {
|
|
579
|
-
this.
|
|
910
|
+
this.sendMessage({
|
|
580
911
|
type: "QUERY_SUB",
|
|
581
912
|
payload: {
|
|
582
913
|
queryId: query.id,
|
|
583
914
|
mapName: query.getMapName(),
|
|
584
915
|
query: query.getFilter()
|
|
585
916
|
}
|
|
586
|
-
})
|
|
917
|
+
});
|
|
587
918
|
}
|
|
588
919
|
requestLock(name, requestId, ttl) {
|
|
589
920
|
if (!this.isAuthenticated()) {
|
|
@@ -598,10 +929,15 @@ var SyncEngine = class {
|
|
|
598
929
|
}, 3e4);
|
|
599
930
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
600
931
|
try {
|
|
601
|
-
this.
|
|
932
|
+
const sent = this.sendMessage({
|
|
602
933
|
type: "LOCK_REQUEST",
|
|
603
934
|
payload: { requestId, name, ttl }
|
|
604
|
-
})
|
|
935
|
+
});
|
|
936
|
+
if (!sent) {
|
|
937
|
+
clearTimeout(timer);
|
|
938
|
+
this.pendingLockRequests.delete(requestId);
|
|
939
|
+
reject(new Error("Failed to send lock request"));
|
|
940
|
+
}
|
|
605
941
|
} catch (e) {
|
|
606
942
|
clearTimeout(timer);
|
|
607
943
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -620,10 +956,15 @@ var SyncEngine = class {
|
|
|
620
956
|
}, 5e3);
|
|
621
957
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
622
958
|
try {
|
|
623
|
-
this.
|
|
959
|
+
const sent = this.sendMessage({
|
|
624
960
|
type: "LOCK_RELEASE",
|
|
625
961
|
payload: { requestId, name, fencingToken }
|
|
626
|
-
})
|
|
962
|
+
});
|
|
963
|
+
if (!sent) {
|
|
964
|
+
clearTimeout(timer);
|
|
965
|
+
this.pendingLockRequests.delete(requestId);
|
|
966
|
+
resolve(false);
|
|
967
|
+
}
|
|
627
968
|
} catch (e) {
|
|
628
969
|
clearTimeout(timer);
|
|
629
970
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -633,6 +974,22 @@ var SyncEngine = class {
|
|
|
633
974
|
}
|
|
634
975
|
async handleServerMessage(message) {
|
|
635
976
|
switch (message.type) {
|
|
977
|
+
case "BATCH": {
|
|
978
|
+
const batchData = message.data;
|
|
979
|
+
const view = new DataView(batchData.buffer, batchData.byteOffset, batchData.byteLength);
|
|
980
|
+
let offset = 0;
|
|
981
|
+
const count = view.getUint32(offset, true);
|
|
982
|
+
offset += 4;
|
|
983
|
+
for (let i = 0; i < count; i++) {
|
|
984
|
+
const msgLen = view.getUint32(offset, true);
|
|
985
|
+
offset += 4;
|
|
986
|
+
const msgData = batchData.slice(offset, offset + msgLen);
|
|
987
|
+
offset += msgLen;
|
|
988
|
+
const innerMsg = deserialize(msgData);
|
|
989
|
+
await this.handleServerMessage(innerMsg);
|
|
990
|
+
}
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
636
993
|
case "AUTH_REQUIRED":
|
|
637
994
|
this.sendAuth();
|
|
638
995
|
break;
|
|
@@ -664,8 +1021,18 @@ var SyncEngine = class {
|
|
|
664
1021
|
this.authToken = null;
|
|
665
1022
|
break;
|
|
666
1023
|
case "OP_ACK": {
|
|
667
|
-
const { lastId } = message.payload;
|
|
668
|
-
logger.info({ lastId }, "Received ACK for ops");
|
|
1024
|
+
const { lastId, achievedLevel, results } = message.payload;
|
|
1025
|
+
logger.info({ lastId, achievedLevel, hasResults: !!results }, "Received ACK for ops");
|
|
1026
|
+
if (results && Array.isArray(results)) {
|
|
1027
|
+
for (const result of results) {
|
|
1028
|
+
const op = this.opLog.find((o) => o.id === result.opId);
|
|
1029
|
+
if (op && !op.synced) {
|
|
1030
|
+
op.synced = true;
|
|
1031
|
+
logger.debug({ opId: result.opId, achievedLevel: result.achievedLevel, success: result.success }, "Op ACK with Write Concern");
|
|
1032
|
+
}
|
|
1033
|
+
this.resolveWriteConcernPromise(result.opId, result);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
669
1036
|
let maxSyncedId = -1;
|
|
670
1037
|
let ackedCount = 0;
|
|
671
1038
|
this.opLog.forEach((op) => {
|
|
@@ -726,18 +1093,20 @@ var SyncEngine = class {
|
|
|
726
1093
|
}
|
|
727
1094
|
case "SERVER_EVENT": {
|
|
728
1095
|
const { mapName, eventType, key, record, orRecord, orTag } = message.payload;
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1096
|
+
await this.applyServerEvent(mapName, eventType, key, record, orRecord, orTag);
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
case "SERVER_BATCH_EVENT": {
|
|
1100
|
+
const { events } = message.payload;
|
|
1101
|
+
for (const event of events) {
|
|
1102
|
+
await this.applyServerEvent(
|
|
1103
|
+
event.mapName,
|
|
1104
|
+
event.eventType,
|
|
1105
|
+
event.key,
|
|
1106
|
+
event.record,
|
|
1107
|
+
event.orRecord,
|
|
1108
|
+
event.orTag
|
|
1109
|
+
);
|
|
741
1110
|
}
|
|
742
1111
|
break;
|
|
743
1112
|
}
|
|
@@ -774,11 +1143,11 @@ var SyncEngine = class {
|
|
|
774
1143
|
const { mapName } = message.payload;
|
|
775
1144
|
logger.warn({ mapName }, "Sync Reset Required due to GC Age");
|
|
776
1145
|
await this.resetMap(mapName);
|
|
777
|
-
this.
|
|
1146
|
+
this.sendMessage({
|
|
778
1147
|
type: "SYNC_INIT",
|
|
779
1148
|
mapName,
|
|
780
1149
|
lastSyncTimestamp: 0
|
|
781
|
-
})
|
|
1150
|
+
});
|
|
782
1151
|
break;
|
|
783
1152
|
}
|
|
784
1153
|
case "SYNC_RESP_ROOT": {
|
|
@@ -788,10 +1157,10 @@ var SyncEngine = class {
|
|
|
788
1157
|
const localRootHash = map.getMerkleTree().getRootHash();
|
|
789
1158
|
if (localRootHash !== rootHash) {
|
|
790
1159
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
|
|
791
|
-
this.
|
|
1160
|
+
this.sendMessage({
|
|
792
1161
|
type: "MERKLE_REQ_BUCKET",
|
|
793
1162
|
payload: { mapName, path: "" }
|
|
794
|
-
})
|
|
1163
|
+
});
|
|
795
1164
|
} else {
|
|
796
1165
|
logger.info({ mapName }, "Map is in sync");
|
|
797
1166
|
}
|
|
@@ -813,10 +1182,10 @@ var SyncEngine = class {
|
|
|
813
1182
|
const localHash = localBuckets[bucketKey] || 0;
|
|
814
1183
|
if (localHash !== remoteHash) {
|
|
815
1184
|
const newPath = path + bucketKey;
|
|
816
|
-
this.
|
|
1185
|
+
this.sendMessage({
|
|
817
1186
|
type: "MERKLE_REQ_BUCKET",
|
|
818
1187
|
payload: { mapName, path: newPath }
|
|
819
|
-
})
|
|
1188
|
+
});
|
|
820
1189
|
}
|
|
821
1190
|
}
|
|
822
1191
|
}
|
|
@@ -849,10 +1218,10 @@ var SyncEngine = class {
|
|
|
849
1218
|
const localRootHash = localTree.getRootHash();
|
|
850
1219
|
if (localRootHash !== rootHash) {
|
|
851
1220
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
|
|
852
|
-
this.
|
|
1221
|
+
this.sendMessage({
|
|
853
1222
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
854
1223
|
payload: { mapName, path: "" }
|
|
855
|
-
})
|
|
1224
|
+
});
|
|
856
1225
|
} else {
|
|
857
1226
|
logger.info({ mapName }, "ORMap is in sync");
|
|
858
1227
|
}
|
|
@@ -874,10 +1243,10 @@ var SyncEngine = class {
|
|
|
874
1243
|
const localHash = localBuckets[bucketKey] || 0;
|
|
875
1244
|
if (localHash !== remoteHash) {
|
|
876
1245
|
const newPath = path + bucketKey;
|
|
877
|
-
this.
|
|
1246
|
+
this.sendMessage({
|
|
878
1247
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
879
1248
|
payload: { mapName, path: newPath }
|
|
880
|
-
})
|
|
1249
|
+
});
|
|
881
1250
|
}
|
|
882
1251
|
}
|
|
883
1252
|
for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
|
|
@@ -940,6 +1309,25 @@ var SyncEngine = class {
|
|
|
940
1309
|
getHLC() {
|
|
941
1310
|
return this.hlc;
|
|
942
1311
|
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Helper method to apply a single server event to the local map.
|
|
1314
|
+
* Used by both SERVER_EVENT and SERVER_BATCH_EVENT handlers.
|
|
1315
|
+
*/
|
|
1316
|
+
async applyServerEvent(mapName, eventType, key, record, orRecord, orTag) {
|
|
1317
|
+
const localMap = this.maps.get(mapName);
|
|
1318
|
+
if (localMap) {
|
|
1319
|
+
if (localMap instanceof LWWMap && record) {
|
|
1320
|
+
localMap.merge(key, record);
|
|
1321
|
+
await this.storageAdapter.put(`${mapName}:${key}`, record);
|
|
1322
|
+
} else if (localMap instanceof ORMap) {
|
|
1323
|
+
if (eventType === "OR_ADD" && orRecord) {
|
|
1324
|
+
localMap.apply(key, orRecord);
|
|
1325
|
+
} else if (eventType === "OR_REMOVE" && orTag) {
|
|
1326
|
+
localMap.applyTombstone(orTag);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
943
1331
|
/**
|
|
944
1332
|
* Closes the WebSocket connection and cleans up resources.
|
|
945
1333
|
*/
|
|
@@ -949,11 +1337,16 @@ var SyncEngine = class {
|
|
|
949
1337
|
clearTimeout(this.reconnectTimer);
|
|
950
1338
|
this.reconnectTimer = null;
|
|
951
1339
|
}
|
|
952
|
-
if (this.
|
|
1340
|
+
if (this.useConnectionProvider) {
|
|
1341
|
+
this.connectionProvider.close().catch((err) => {
|
|
1342
|
+
logger.error({ err }, "Error closing ConnectionProvider");
|
|
1343
|
+
});
|
|
1344
|
+
} else if (this.websocket) {
|
|
953
1345
|
this.websocket.onclose = null;
|
|
954
1346
|
this.websocket.close();
|
|
955
1347
|
this.websocket = null;
|
|
956
1348
|
}
|
|
1349
|
+
this.cancelAllWriteConcernPromises(new Error("SyncEngine closed"));
|
|
957
1350
|
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
958
1351
|
logger.info("SyncEngine closed");
|
|
959
1352
|
}
|
|
@@ -965,7 +1358,100 @@ var SyncEngine = class {
|
|
|
965
1358
|
this.close();
|
|
966
1359
|
this.stateMachine.reset();
|
|
967
1360
|
this.resetBackoff();
|
|
968
|
-
this.
|
|
1361
|
+
if (this.useConnectionProvider) {
|
|
1362
|
+
this.initConnectionProvider();
|
|
1363
|
+
} else {
|
|
1364
|
+
this.initConnection();
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
// ============================================
|
|
1368
|
+
// Failover Support Methods (Phase 4.5 Task 05)
|
|
1369
|
+
// ============================================
|
|
1370
|
+
/**
|
|
1371
|
+
* Wait for a partition map update from the connection provider.
|
|
1372
|
+
* Used when an operation fails with NOT_OWNER error and needs
|
|
1373
|
+
* to wait for an updated partition map before retrying.
|
|
1374
|
+
*
|
|
1375
|
+
* @param timeoutMs - Maximum time to wait (default: 5000ms)
|
|
1376
|
+
* @returns Promise that resolves when partition map is updated or times out
|
|
1377
|
+
*/
|
|
1378
|
+
waitForPartitionMapUpdate(timeoutMs = 5e3) {
|
|
1379
|
+
return new Promise((resolve) => {
|
|
1380
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
1381
|
+
const handler2 = () => {
|
|
1382
|
+
clearTimeout(timeout);
|
|
1383
|
+
this.connectionProvider.off("partitionMapUpdated", handler2);
|
|
1384
|
+
resolve();
|
|
1385
|
+
};
|
|
1386
|
+
this.connectionProvider.on("partitionMapUpdated", handler2);
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Wait for the connection to be available.
|
|
1391
|
+
* Used when an operation fails due to connection issues and needs
|
|
1392
|
+
* to wait for reconnection before retrying.
|
|
1393
|
+
*
|
|
1394
|
+
* @param timeoutMs - Maximum time to wait (default: 10000ms)
|
|
1395
|
+
* @returns Promise that resolves when connected or rejects on timeout
|
|
1396
|
+
*/
|
|
1397
|
+
waitForConnection(timeoutMs = 1e4) {
|
|
1398
|
+
return new Promise((resolve, reject) => {
|
|
1399
|
+
if (this.connectionProvider.isConnected()) {
|
|
1400
|
+
resolve();
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
const timeout = setTimeout(() => {
|
|
1404
|
+
this.connectionProvider.off("connected", handler2);
|
|
1405
|
+
reject(new Error("Connection timeout waiting for reconnection"));
|
|
1406
|
+
}, timeoutMs);
|
|
1407
|
+
const handler2 = () => {
|
|
1408
|
+
clearTimeout(timeout);
|
|
1409
|
+
this.connectionProvider.off("connected", handler2);
|
|
1410
|
+
resolve();
|
|
1411
|
+
};
|
|
1412
|
+
this.connectionProvider.on("connected", handler2);
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Wait for a specific sync state.
|
|
1417
|
+
* Useful for waiting until fully connected and synced.
|
|
1418
|
+
*
|
|
1419
|
+
* @param targetState - The state to wait for
|
|
1420
|
+
* @param timeoutMs - Maximum time to wait (default: 30000ms)
|
|
1421
|
+
* @returns Promise that resolves when state is reached or rejects on timeout
|
|
1422
|
+
*/
|
|
1423
|
+
waitForState(targetState, timeoutMs = 3e4) {
|
|
1424
|
+
return new Promise((resolve, reject) => {
|
|
1425
|
+
if (this.stateMachine.getState() === targetState) {
|
|
1426
|
+
resolve();
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
const timeout = setTimeout(() => {
|
|
1430
|
+
unsubscribe();
|
|
1431
|
+
reject(new Error(`Timeout waiting for state ${targetState}`));
|
|
1432
|
+
}, timeoutMs);
|
|
1433
|
+
const unsubscribe = this.stateMachine.onStateChange((event) => {
|
|
1434
|
+
if (event.to === targetState) {
|
|
1435
|
+
clearTimeout(timeout);
|
|
1436
|
+
unsubscribe();
|
|
1437
|
+
resolve();
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Check if the connection provider is connected.
|
|
1444
|
+
* Convenience method for failover logic.
|
|
1445
|
+
*/
|
|
1446
|
+
isProviderConnected() {
|
|
1447
|
+
return this.connectionProvider.isConnected();
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Get the connection provider for direct access.
|
|
1451
|
+
* Use with caution - prefer using SyncEngine methods.
|
|
1452
|
+
*/
|
|
1453
|
+
getConnectionProvider() {
|
|
1454
|
+
return this.connectionProvider;
|
|
969
1455
|
}
|
|
970
1456
|
async resetMap(mapName) {
|
|
971
1457
|
const map = this.maps.get(mapName);
|
|
@@ -1013,12 +1499,12 @@ var SyncEngine = class {
|
|
|
1013
1499
|
* Sends a PING message to the server.
|
|
1014
1500
|
*/
|
|
1015
1501
|
sendPing() {
|
|
1016
|
-
if (this.
|
|
1502
|
+
if (this.canSend()) {
|
|
1017
1503
|
const pingMessage = {
|
|
1018
1504
|
type: "PING",
|
|
1019
1505
|
timestamp: Date.now()
|
|
1020
1506
|
};
|
|
1021
|
-
this.
|
|
1507
|
+
this.sendMessage(pingMessage);
|
|
1022
1508
|
}
|
|
1023
1509
|
}
|
|
1024
1510
|
/**
|
|
@@ -1097,13 +1583,13 @@ var SyncEngine = class {
|
|
|
1097
1583
|
}
|
|
1098
1584
|
}
|
|
1099
1585
|
if (entries.length > 0) {
|
|
1100
|
-
this.
|
|
1586
|
+
this.sendMessage({
|
|
1101
1587
|
type: "ORMAP_PUSH_DIFF",
|
|
1102
1588
|
payload: {
|
|
1103
1589
|
mapName,
|
|
1104
1590
|
entries
|
|
1105
1591
|
}
|
|
1106
|
-
})
|
|
1592
|
+
});
|
|
1107
1593
|
logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
|
|
1108
1594
|
}
|
|
1109
1595
|
}
|
|
@@ -1274,21 +1760,73 @@ var SyncEngine = class {
|
|
|
1274
1760
|
});
|
|
1275
1761
|
}
|
|
1276
1762
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
//
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1763
|
+
// ============================================
|
|
1764
|
+
// Write Concern Methods (Phase 5.01)
|
|
1765
|
+
// ============================================
|
|
1766
|
+
/**
|
|
1767
|
+
* Register a pending Write Concern promise for an operation.
|
|
1768
|
+
* The promise will be resolved when the server sends an ACK with the operation result.
|
|
1769
|
+
*
|
|
1770
|
+
* @param opId - Operation ID
|
|
1771
|
+
* @param timeout - Timeout in ms (default: 5000)
|
|
1772
|
+
* @returns Promise that resolves with the Write Concern result
|
|
1773
|
+
*/
|
|
1774
|
+
registerWriteConcernPromise(opId, timeout = 5e3) {
|
|
1775
|
+
return new Promise((resolve, reject) => {
|
|
1776
|
+
const timeoutHandle = setTimeout(() => {
|
|
1777
|
+
this.pendingWriteConcernPromises.delete(opId);
|
|
1778
|
+
reject(new Error(`Write Concern timeout for operation ${opId}`));
|
|
1779
|
+
}, timeout);
|
|
1780
|
+
this.pendingWriteConcernPromises.set(opId, {
|
|
1781
|
+
resolve,
|
|
1782
|
+
reject,
|
|
1783
|
+
timeoutHandle
|
|
1784
|
+
});
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Resolve a pending Write Concern promise with the server result.
|
|
1789
|
+
*
|
|
1790
|
+
* @param opId - Operation ID
|
|
1791
|
+
* @param result - Result from server ACK
|
|
1792
|
+
*/
|
|
1793
|
+
resolveWriteConcernPromise(opId, result) {
|
|
1794
|
+
const pending = this.pendingWriteConcernPromises.get(opId);
|
|
1795
|
+
if (pending) {
|
|
1796
|
+
if (pending.timeoutHandle) {
|
|
1797
|
+
clearTimeout(pending.timeoutHandle);
|
|
1798
|
+
}
|
|
1799
|
+
pending.resolve(result);
|
|
1800
|
+
this.pendingWriteConcernPromises.delete(opId);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Cancel all pending Write Concern promises (e.g., on disconnect).
|
|
1805
|
+
*/
|
|
1806
|
+
cancelAllWriteConcernPromises(error) {
|
|
1807
|
+
for (const [opId, pending] of this.pendingWriteConcernPromises.entries()) {
|
|
1808
|
+
if (pending.timeoutHandle) {
|
|
1809
|
+
clearTimeout(pending.timeoutHandle);
|
|
1810
|
+
}
|
|
1811
|
+
pending.reject(error);
|
|
1812
|
+
}
|
|
1813
|
+
this.pendingWriteConcernPromises.clear();
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
1816
|
+
|
|
1817
|
+
// src/TopGunClient.ts
|
|
1818
|
+
import { LWWMap as LWWMap2, ORMap as ORMap2 } from "@topgunbuild/core";
|
|
1819
|
+
|
|
1820
|
+
// src/QueryHandle.ts
|
|
1821
|
+
var QueryHandle = class {
|
|
1822
|
+
constructor(syncEngine, mapName, filter = {}) {
|
|
1823
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1824
|
+
this.currentResults = /* @__PURE__ */ new Map();
|
|
1825
|
+
// Track if we've received authoritative server response
|
|
1826
|
+
this.hasReceivedServerData = false;
|
|
1827
|
+
this.id = crypto.randomUUID();
|
|
1828
|
+
this.syncEngine = syncEngine;
|
|
1829
|
+
this.mapName = mapName;
|
|
1292
1830
|
this.filter = filter;
|
|
1293
1831
|
}
|
|
1294
1832
|
subscribe(callback) {
|
|
@@ -1347,152 +1885,1626 @@ var QueryHandle = class {
|
|
|
1347
1885
|
this.currentResults.delete(key);
|
|
1348
1886
|
}
|
|
1349
1887
|
}
|
|
1350
|
-
if (removedKeys.length > 0) {
|
|
1351
|
-
console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
|
|
1352
|
-
}
|
|
1353
|
-
for (const item of items) {
|
|
1354
|
-
this.currentResults.set(item.key, item.value);
|
|
1888
|
+
if (removedKeys.length > 0) {
|
|
1889
|
+
console.log(`[QueryHandle:${this.mapName}] Removed ${removedKeys.length} keys:`, removedKeys);
|
|
1890
|
+
}
|
|
1891
|
+
for (const item of items) {
|
|
1892
|
+
this.currentResults.set(item.key, item.value);
|
|
1893
|
+
}
|
|
1894
|
+
console.log(`[QueryHandle:${this.mapName}] After merge: ${this.currentResults.size} results`);
|
|
1895
|
+
this.notify();
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Called by SyncEngine when server sends a live update
|
|
1899
|
+
*/
|
|
1900
|
+
onUpdate(key, value) {
|
|
1901
|
+
if (value === null) {
|
|
1902
|
+
this.currentResults.delete(key);
|
|
1903
|
+
} else {
|
|
1904
|
+
this.currentResults.set(key, value);
|
|
1905
|
+
}
|
|
1906
|
+
this.notify();
|
|
1907
|
+
}
|
|
1908
|
+
notify() {
|
|
1909
|
+
const results = this.getSortedResults();
|
|
1910
|
+
for (const listener of this.listeners) {
|
|
1911
|
+
listener(results);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
getSortedResults() {
|
|
1915
|
+
const results = Array.from(this.currentResults.entries()).map(
|
|
1916
|
+
([key, value]) => ({ ...value, _key: key })
|
|
1917
|
+
);
|
|
1918
|
+
if (this.filter.sort) {
|
|
1919
|
+
results.sort((a, b) => {
|
|
1920
|
+
for (const [field, direction] of Object.entries(this.filter.sort)) {
|
|
1921
|
+
const valA = a[field];
|
|
1922
|
+
const valB = b[field];
|
|
1923
|
+
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
1924
|
+
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
1925
|
+
}
|
|
1926
|
+
return 0;
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
return results;
|
|
1930
|
+
}
|
|
1931
|
+
getFilter() {
|
|
1932
|
+
return this.filter;
|
|
1933
|
+
}
|
|
1934
|
+
getMapName() {
|
|
1935
|
+
return this.mapName;
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
|
|
1939
|
+
// src/DistributedLock.ts
|
|
1940
|
+
var DistributedLock = class {
|
|
1941
|
+
constructor(syncEngine, name) {
|
|
1942
|
+
this.fencingToken = null;
|
|
1943
|
+
this._isLocked = false;
|
|
1944
|
+
this.syncEngine = syncEngine;
|
|
1945
|
+
this.name = name;
|
|
1946
|
+
}
|
|
1947
|
+
async lock(ttl = 1e4) {
|
|
1948
|
+
const requestId = crypto.randomUUID();
|
|
1949
|
+
try {
|
|
1950
|
+
const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
|
|
1951
|
+
this.fencingToken = result.fencingToken;
|
|
1952
|
+
this._isLocked = true;
|
|
1953
|
+
return true;
|
|
1954
|
+
} catch (e) {
|
|
1955
|
+
return false;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
async unlock() {
|
|
1959
|
+
if (!this._isLocked || this.fencingToken === null) return;
|
|
1960
|
+
const requestId = crypto.randomUUID();
|
|
1961
|
+
try {
|
|
1962
|
+
await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
|
|
1963
|
+
} finally {
|
|
1964
|
+
this._isLocked = false;
|
|
1965
|
+
this.fencingToken = null;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
isLocked() {
|
|
1969
|
+
return this._isLocked;
|
|
1970
|
+
}
|
|
1971
|
+
};
|
|
1972
|
+
|
|
1973
|
+
// src/TopicHandle.ts
|
|
1974
|
+
var TopicHandle = class {
|
|
1975
|
+
constructor(engine, topic) {
|
|
1976
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1977
|
+
this.engine = engine;
|
|
1978
|
+
this.topic = topic;
|
|
1979
|
+
}
|
|
1980
|
+
get id() {
|
|
1981
|
+
return this.topic;
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Publish a message to the topic
|
|
1985
|
+
*/
|
|
1986
|
+
publish(data) {
|
|
1987
|
+
this.engine.publishTopic(this.topic, data);
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Subscribe to the topic
|
|
1991
|
+
*/
|
|
1992
|
+
subscribe(callback) {
|
|
1993
|
+
if (this.listeners.size === 0) {
|
|
1994
|
+
this.engine.subscribeToTopic(this.topic, this);
|
|
1995
|
+
}
|
|
1996
|
+
this.listeners.add(callback);
|
|
1997
|
+
return () => this.unsubscribe(callback);
|
|
1998
|
+
}
|
|
1999
|
+
unsubscribe(callback) {
|
|
2000
|
+
this.listeners.delete(callback);
|
|
2001
|
+
if (this.listeners.size === 0) {
|
|
2002
|
+
this.engine.unsubscribeFromTopic(this.topic);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Called by SyncEngine when a message is received
|
|
2007
|
+
*/
|
|
2008
|
+
onMessage(data, context) {
|
|
2009
|
+
this.listeners.forEach((cb) => {
|
|
2010
|
+
try {
|
|
2011
|
+
cb(data, context);
|
|
2012
|
+
} catch (e) {
|
|
2013
|
+
console.error("Error in topic listener", e);
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
|
|
2019
|
+
// src/cluster/ClusterClient.ts
|
|
2020
|
+
import {
|
|
2021
|
+
DEFAULT_CONNECTION_POOL_CONFIG as DEFAULT_CONNECTION_POOL_CONFIG2,
|
|
2022
|
+
DEFAULT_PARTITION_ROUTER_CONFIG as DEFAULT_PARTITION_ROUTER_CONFIG2,
|
|
2023
|
+
DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
2024
|
+
serialize as serialize3
|
|
2025
|
+
} from "@topgunbuild/core";
|
|
2026
|
+
|
|
2027
|
+
// src/cluster/ConnectionPool.ts
|
|
2028
|
+
import {
|
|
2029
|
+
DEFAULT_CONNECTION_POOL_CONFIG
|
|
2030
|
+
} from "@topgunbuild/core";
|
|
2031
|
+
import { serialize as serialize2, deserialize as deserialize2 } from "@topgunbuild/core";
|
|
2032
|
+
var ConnectionPool = class {
|
|
2033
|
+
constructor(config = {}) {
|
|
2034
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2035
|
+
this.connections = /* @__PURE__ */ new Map();
|
|
2036
|
+
this.primaryNodeId = null;
|
|
2037
|
+
this.healthCheckTimer = null;
|
|
2038
|
+
this.authToken = null;
|
|
2039
|
+
this.config = {
|
|
2040
|
+
...DEFAULT_CONNECTION_POOL_CONFIG,
|
|
2041
|
+
...config
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
// ============================================
|
|
2045
|
+
// Event Emitter Methods (browser-compatible)
|
|
2046
|
+
// ============================================
|
|
2047
|
+
on(event, listener) {
|
|
2048
|
+
if (!this.listeners.has(event)) {
|
|
2049
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2050
|
+
}
|
|
2051
|
+
this.listeners.get(event).add(listener);
|
|
2052
|
+
return this;
|
|
2053
|
+
}
|
|
2054
|
+
off(event, listener) {
|
|
2055
|
+
this.listeners.get(event)?.delete(listener);
|
|
2056
|
+
return this;
|
|
2057
|
+
}
|
|
2058
|
+
emit(event, ...args) {
|
|
2059
|
+
const eventListeners = this.listeners.get(event);
|
|
2060
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
2061
|
+
return false;
|
|
2062
|
+
}
|
|
2063
|
+
for (const listener of eventListeners) {
|
|
2064
|
+
try {
|
|
2065
|
+
listener(...args);
|
|
2066
|
+
} catch (err) {
|
|
2067
|
+
logger.error({ event, err }, "Error in event listener");
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
return true;
|
|
2071
|
+
}
|
|
2072
|
+
removeAllListeners(event) {
|
|
2073
|
+
if (event) {
|
|
2074
|
+
this.listeners.delete(event);
|
|
2075
|
+
} else {
|
|
2076
|
+
this.listeners.clear();
|
|
2077
|
+
}
|
|
2078
|
+
return this;
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Set authentication token for all connections
|
|
2082
|
+
*/
|
|
2083
|
+
setAuthToken(token) {
|
|
2084
|
+
this.authToken = token;
|
|
2085
|
+
for (const conn of this.connections.values()) {
|
|
2086
|
+
if (conn.state === "CONNECTED") {
|
|
2087
|
+
this.sendAuth(conn);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Add a node to the connection pool
|
|
2093
|
+
*/
|
|
2094
|
+
async addNode(nodeId, endpoint) {
|
|
2095
|
+
if (this.connections.has(nodeId)) {
|
|
2096
|
+
const existing = this.connections.get(nodeId);
|
|
2097
|
+
if (existing.endpoint !== endpoint) {
|
|
2098
|
+
await this.removeNode(nodeId);
|
|
2099
|
+
} else {
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
const connection = {
|
|
2104
|
+
nodeId,
|
|
2105
|
+
endpoint,
|
|
2106
|
+
socket: null,
|
|
2107
|
+
state: "DISCONNECTED",
|
|
2108
|
+
lastSeen: 0,
|
|
2109
|
+
latencyMs: 0,
|
|
2110
|
+
reconnectAttempts: 0,
|
|
2111
|
+
reconnectTimer: null,
|
|
2112
|
+
pendingMessages: []
|
|
2113
|
+
};
|
|
2114
|
+
this.connections.set(nodeId, connection);
|
|
2115
|
+
if (!this.primaryNodeId) {
|
|
2116
|
+
this.primaryNodeId = nodeId;
|
|
2117
|
+
}
|
|
2118
|
+
await this.connect(nodeId);
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Remove a node from the connection pool
|
|
2122
|
+
*/
|
|
2123
|
+
async removeNode(nodeId) {
|
|
2124
|
+
const connection = this.connections.get(nodeId);
|
|
2125
|
+
if (!connection) return;
|
|
2126
|
+
if (connection.reconnectTimer) {
|
|
2127
|
+
clearTimeout(connection.reconnectTimer);
|
|
2128
|
+
connection.reconnectTimer = null;
|
|
2129
|
+
}
|
|
2130
|
+
if (connection.socket) {
|
|
2131
|
+
connection.socket.onclose = null;
|
|
2132
|
+
connection.socket.close();
|
|
2133
|
+
connection.socket = null;
|
|
2134
|
+
}
|
|
2135
|
+
this.connections.delete(nodeId);
|
|
2136
|
+
if (this.primaryNodeId === nodeId) {
|
|
2137
|
+
this.primaryNodeId = this.connections.size > 0 ? this.connections.keys().next().value ?? null : null;
|
|
2138
|
+
}
|
|
2139
|
+
logger.info({ nodeId }, "Node removed from connection pool");
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Get connection for a specific node
|
|
2143
|
+
*/
|
|
2144
|
+
getConnection(nodeId) {
|
|
2145
|
+
const connection = this.connections.get(nodeId);
|
|
2146
|
+
if (!connection || connection.state !== "AUTHENTICATED") {
|
|
2147
|
+
return null;
|
|
2148
|
+
}
|
|
2149
|
+
return connection.socket;
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Get primary connection (first/seed node)
|
|
2153
|
+
*/
|
|
2154
|
+
getPrimaryConnection() {
|
|
2155
|
+
if (!this.primaryNodeId) return null;
|
|
2156
|
+
return this.getConnection(this.primaryNodeId);
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Get any healthy connection
|
|
2160
|
+
*/
|
|
2161
|
+
getAnyHealthyConnection() {
|
|
2162
|
+
for (const [nodeId, conn] of this.connections) {
|
|
2163
|
+
if (conn.state === "AUTHENTICATED" && conn.socket) {
|
|
2164
|
+
return { nodeId, socket: conn.socket };
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
return null;
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Send message to a specific node
|
|
2171
|
+
*/
|
|
2172
|
+
send(nodeId, message) {
|
|
2173
|
+
const connection = this.connections.get(nodeId);
|
|
2174
|
+
if (!connection) {
|
|
2175
|
+
logger.warn({ nodeId }, "Cannot send: node not in pool");
|
|
2176
|
+
return false;
|
|
2177
|
+
}
|
|
2178
|
+
const data = serialize2(message);
|
|
2179
|
+
if (connection.state === "AUTHENTICATED" && connection.socket?.readyState === WebSocket.OPEN) {
|
|
2180
|
+
connection.socket.send(data);
|
|
2181
|
+
return true;
|
|
2182
|
+
}
|
|
2183
|
+
if (connection.pendingMessages.length < 1e3) {
|
|
2184
|
+
connection.pendingMessages.push(data);
|
|
2185
|
+
return true;
|
|
2186
|
+
}
|
|
2187
|
+
logger.warn({ nodeId }, "Message queue full, dropping message");
|
|
2188
|
+
return false;
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Send message to primary node
|
|
2192
|
+
*/
|
|
2193
|
+
sendToPrimary(message) {
|
|
2194
|
+
if (!this.primaryNodeId) {
|
|
2195
|
+
logger.warn("No primary node available");
|
|
2196
|
+
return false;
|
|
2197
|
+
}
|
|
2198
|
+
return this.send(this.primaryNodeId, message);
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Get health status for all nodes
|
|
2202
|
+
*/
|
|
2203
|
+
getHealthStatus() {
|
|
2204
|
+
const status = /* @__PURE__ */ new Map();
|
|
2205
|
+
for (const [nodeId, conn] of this.connections) {
|
|
2206
|
+
status.set(nodeId, {
|
|
2207
|
+
nodeId,
|
|
2208
|
+
state: conn.state,
|
|
2209
|
+
lastSeen: conn.lastSeen,
|
|
2210
|
+
latencyMs: conn.latencyMs,
|
|
2211
|
+
reconnectAttempts: conn.reconnectAttempts
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
return status;
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Get list of connected node IDs
|
|
2218
|
+
*/
|
|
2219
|
+
getConnectedNodes() {
|
|
2220
|
+
return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* Get all node IDs
|
|
2224
|
+
*/
|
|
2225
|
+
getAllNodes() {
|
|
2226
|
+
return Array.from(this.connections.keys());
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* Check if node is connected and authenticated
|
|
2230
|
+
*/
|
|
2231
|
+
isNodeConnected(nodeId) {
|
|
2232
|
+
const conn = this.connections.get(nodeId);
|
|
2233
|
+
return conn?.state === "AUTHENTICATED";
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Check if connected to a specific node.
|
|
2237
|
+
* Alias for isNodeConnected() for IConnectionProvider compatibility.
|
|
2238
|
+
*/
|
|
2239
|
+
isConnected(nodeId) {
|
|
2240
|
+
return this.isNodeConnected(nodeId);
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Start health monitoring
|
|
2244
|
+
*/
|
|
2245
|
+
startHealthCheck() {
|
|
2246
|
+
if (this.healthCheckTimer) return;
|
|
2247
|
+
this.healthCheckTimer = setInterval(() => {
|
|
2248
|
+
this.performHealthCheck();
|
|
2249
|
+
}, this.config.healthCheckIntervalMs);
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Stop health monitoring
|
|
2253
|
+
*/
|
|
2254
|
+
stopHealthCheck() {
|
|
2255
|
+
if (this.healthCheckTimer) {
|
|
2256
|
+
clearInterval(this.healthCheckTimer);
|
|
2257
|
+
this.healthCheckTimer = null;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Close all connections and cleanup
|
|
2262
|
+
*/
|
|
2263
|
+
close() {
|
|
2264
|
+
this.stopHealthCheck();
|
|
2265
|
+
for (const nodeId of this.connections.keys()) {
|
|
2266
|
+
this.removeNode(nodeId);
|
|
2267
|
+
}
|
|
2268
|
+
this.connections.clear();
|
|
2269
|
+
this.primaryNodeId = null;
|
|
2270
|
+
}
|
|
2271
|
+
// ============================================
|
|
2272
|
+
// Private Methods
|
|
2273
|
+
// ============================================
|
|
2274
|
+
async connect(nodeId) {
|
|
2275
|
+
const connection = this.connections.get(nodeId);
|
|
2276
|
+
if (!connection) return;
|
|
2277
|
+
if (connection.state === "CONNECTING" || connection.state === "CONNECTED") {
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
connection.state = "CONNECTING";
|
|
2281
|
+
logger.info({ nodeId, endpoint: connection.endpoint }, "Connecting to node");
|
|
2282
|
+
try {
|
|
2283
|
+
const socket = new WebSocket(connection.endpoint);
|
|
2284
|
+
socket.binaryType = "arraybuffer";
|
|
2285
|
+
connection.socket = socket;
|
|
2286
|
+
socket.onopen = () => {
|
|
2287
|
+
connection.state = "CONNECTED";
|
|
2288
|
+
connection.reconnectAttempts = 0;
|
|
2289
|
+
connection.lastSeen = Date.now();
|
|
2290
|
+
logger.info({ nodeId }, "Connected to node");
|
|
2291
|
+
this.emit("node:connected", nodeId);
|
|
2292
|
+
if (this.authToken) {
|
|
2293
|
+
this.sendAuth(connection);
|
|
2294
|
+
}
|
|
2295
|
+
};
|
|
2296
|
+
socket.onmessage = (event) => {
|
|
2297
|
+
connection.lastSeen = Date.now();
|
|
2298
|
+
this.handleMessage(nodeId, event);
|
|
2299
|
+
};
|
|
2300
|
+
socket.onerror = (error) => {
|
|
2301
|
+
logger.error({ nodeId, error }, "WebSocket error");
|
|
2302
|
+
this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
|
|
2303
|
+
};
|
|
2304
|
+
socket.onclose = () => {
|
|
2305
|
+
const wasConnected = connection.state === "AUTHENTICATED";
|
|
2306
|
+
connection.state = "DISCONNECTED";
|
|
2307
|
+
connection.socket = null;
|
|
2308
|
+
if (wasConnected) {
|
|
2309
|
+
this.emit("node:disconnected", nodeId, "Connection closed");
|
|
2310
|
+
}
|
|
2311
|
+
this.scheduleReconnect(nodeId);
|
|
2312
|
+
};
|
|
2313
|
+
} catch (error) {
|
|
2314
|
+
connection.state = "FAILED";
|
|
2315
|
+
logger.error({ nodeId, error }, "Failed to connect");
|
|
2316
|
+
this.scheduleReconnect(nodeId);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
sendAuth(connection) {
|
|
2320
|
+
if (!this.authToken || !connection.socket) return;
|
|
2321
|
+
connection.socket.send(serialize2({
|
|
2322
|
+
type: "AUTH",
|
|
2323
|
+
token: this.authToken
|
|
2324
|
+
}));
|
|
2325
|
+
}
|
|
2326
|
+
handleMessage(nodeId, event) {
|
|
2327
|
+
const connection = this.connections.get(nodeId);
|
|
2328
|
+
if (!connection) return;
|
|
2329
|
+
let message;
|
|
2330
|
+
try {
|
|
2331
|
+
if (event.data instanceof ArrayBuffer) {
|
|
2332
|
+
message = deserialize2(new Uint8Array(event.data));
|
|
2333
|
+
} else {
|
|
2334
|
+
message = JSON.parse(event.data);
|
|
2335
|
+
}
|
|
2336
|
+
} catch (e) {
|
|
2337
|
+
logger.error({ nodeId, error: e }, "Failed to parse message");
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
if (message.type === "AUTH_ACK") {
|
|
2341
|
+
connection.state = "AUTHENTICATED";
|
|
2342
|
+
logger.info({ nodeId }, "Authenticated with node");
|
|
2343
|
+
this.emit("node:healthy", nodeId);
|
|
2344
|
+
this.flushPendingMessages(connection);
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
if (message.type === "AUTH_REQUIRED") {
|
|
2348
|
+
if (this.authToken) {
|
|
2349
|
+
this.sendAuth(connection);
|
|
2350
|
+
}
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
if (message.type === "AUTH_FAIL") {
|
|
2354
|
+
logger.error({ nodeId, error: message.error }, "Authentication failed");
|
|
2355
|
+
connection.state = "FAILED";
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
if (message.type === "PONG") {
|
|
2359
|
+
if (message.timestamp) {
|
|
2360
|
+
connection.latencyMs = Date.now() - message.timestamp;
|
|
2361
|
+
}
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
|
|
2365
|
+
this.emit("message", nodeId, message);
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
this.emit("message", nodeId, message);
|
|
2369
|
+
}
|
|
2370
|
+
flushPendingMessages(connection) {
|
|
2371
|
+
if (!connection.socket || connection.state !== "AUTHENTICATED") return;
|
|
2372
|
+
const pending = connection.pendingMessages;
|
|
2373
|
+
connection.pendingMessages = [];
|
|
2374
|
+
for (const data of pending) {
|
|
2375
|
+
if (connection.socket.readyState === WebSocket.OPEN) {
|
|
2376
|
+
connection.socket.send(data);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
if (pending.length > 0) {
|
|
2380
|
+
logger.debug({ nodeId: connection.nodeId, count: pending.length }, "Flushed pending messages");
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
scheduleReconnect(nodeId) {
|
|
2384
|
+
const connection = this.connections.get(nodeId);
|
|
2385
|
+
if (!connection) return;
|
|
2386
|
+
if (connection.reconnectTimer) {
|
|
2387
|
+
clearTimeout(connection.reconnectTimer);
|
|
2388
|
+
connection.reconnectTimer = null;
|
|
2389
|
+
}
|
|
2390
|
+
if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
2391
|
+
connection.state = "FAILED";
|
|
2392
|
+
logger.error({ nodeId, attempts: connection.reconnectAttempts }, "Max reconnect attempts reached");
|
|
2393
|
+
this.emit("node:unhealthy", nodeId, "Max reconnect attempts reached");
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
const delay = Math.min(
|
|
2397
|
+
this.config.reconnectDelayMs * Math.pow(2, connection.reconnectAttempts),
|
|
2398
|
+
this.config.maxReconnectDelayMs
|
|
2399
|
+
);
|
|
2400
|
+
connection.state = "RECONNECTING";
|
|
2401
|
+
connection.reconnectAttempts++;
|
|
2402
|
+
logger.info({ nodeId, delay, attempt: connection.reconnectAttempts }, "Scheduling reconnect");
|
|
2403
|
+
connection.reconnectTimer = setTimeout(() => {
|
|
2404
|
+
connection.reconnectTimer = null;
|
|
2405
|
+
this.connect(nodeId);
|
|
2406
|
+
}, delay);
|
|
2407
|
+
}
|
|
2408
|
+
performHealthCheck() {
|
|
2409
|
+
const now = Date.now();
|
|
2410
|
+
for (const [nodeId, connection] of this.connections) {
|
|
2411
|
+
if (connection.state !== "AUTHENTICATED") continue;
|
|
2412
|
+
const timeSinceLastSeen = now - connection.lastSeen;
|
|
2413
|
+
if (timeSinceLastSeen > this.config.healthCheckIntervalMs * 3) {
|
|
2414
|
+
logger.warn({ nodeId, timeSinceLastSeen }, "Node appears stale, sending ping");
|
|
2415
|
+
}
|
|
2416
|
+
if (connection.socket?.readyState === WebSocket.OPEN) {
|
|
2417
|
+
connection.socket.send(serialize2({
|
|
2418
|
+
type: "PING",
|
|
2419
|
+
timestamp: now
|
|
2420
|
+
}));
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
};
|
|
2425
|
+
|
|
2426
|
+
// src/cluster/PartitionRouter.ts
|
|
2427
|
+
import {
|
|
2428
|
+
DEFAULT_PARTITION_ROUTER_CONFIG,
|
|
2429
|
+
PARTITION_COUNT,
|
|
2430
|
+
hashString
|
|
2431
|
+
} from "@topgunbuild/core";
|
|
2432
|
+
var PartitionRouter = class {
|
|
2433
|
+
constructor(connectionPool, config = {}) {
|
|
2434
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2435
|
+
this.partitionMap = null;
|
|
2436
|
+
this.lastRefreshTime = 0;
|
|
2437
|
+
this.refreshTimer = null;
|
|
2438
|
+
this.pendingRefresh = null;
|
|
2439
|
+
this.connectionPool = connectionPool;
|
|
2440
|
+
this.config = {
|
|
2441
|
+
...DEFAULT_PARTITION_ROUTER_CONFIG,
|
|
2442
|
+
...config
|
|
2443
|
+
};
|
|
2444
|
+
this.connectionPool.on("message", (nodeId, message) => {
|
|
2445
|
+
if (message.type === "PARTITION_MAP") {
|
|
2446
|
+
this.handlePartitionMap(message);
|
|
2447
|
+
} else if (message.type === "PARTITION_MAP_DELTA") {
|
|
2448
|
+
this.handlePartitionMapDelta(message);
|
|
2449
|
+
}
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
// ============================================
|
|
2453
|
+
// Event Emitter Methods (browser-compatible)
|
|
2454
|
+
// ============================================
|
|
2455
|
+
on(event, listener) {
|
|
2456
|
+
if (!this.listeners.has(event)) {
|
|
2457
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2458
|
+
}
|
|
2459
|
+
this.listeners.get(event).add(listener);
|
|
2460
|
+
return this;
|
|
2461
|
+
}
|
|
2462
|
+
off(event, listener) {
|
|
2463
|
+
this.listeners.get(event)?.delete(listener);
|
|
2464
|
+
return this;
|
|
2465
|
+
}
|
|
2466
|
+
once(event, listener) {
|
|
2467
|
+
const wrapper = (...args) => {
|
|
2468
|
+
this.off(event, wrapper);
|
|
2469
|
+
listener(...args);
|
|
2470
|
+
};
|
|
2471
|
+
return this.on(event, wrapper);
|
|
2472
|
+
}
|
|
2473
|
+
emit(event, ...args) {
|
|
2474
|
+
const eventListeners = this.listeners.get(event);
|
|
2475
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
2476
|
+
return false;
|
|
2477
|
+
}
|
|
2478
|
+
for (const listener of eventListeners) {
|
|
2479
|
+
try {
|
|
2480
|
+
listener(...args);
|
|
2481
|
+
} catch (err) {
|
|
2482
|
+
logger.error({ event, err }, "Error in event listener");
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
return true;
|
|
2486
|
+
}
|
|
2487
|
+
removeListener(event, listener) {
|
|
2488
|
+
return this.off(event, listener);
|
|
2489
|
+
}
|
|
2490
|
+
removeAllListeners(event) {
|
|
2491
|
+
if (event) {
|
|
2492
|
+
this.listeners.delete(event);
|
|
2493
|
+
} else {
|
|
2494
|
+
this.listeners.clear();
|
|
2495
|
+
}
|
|
2496
|
+
return this;
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Get the partition ID for a given key
|
|
2500
|
+
*/
|
|
2501
|
+
getPartitionId(key) {
|
|
2502
|
+
return Math.abs(hashString(key)) % PARTITION_COUNT;
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* Route a key to the owner node
|
|
2506
|
+
*/
|
|
2507
|
+
route(key) {
|
|
2508
|
+
if (!this.partitionMap) {
|
|
2509
|
+
return null;
|
|
2510
|
+
}
|
|
2511
|
+
const partitionId = this.getPartitionId(key);
|
|
2512
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
2513
|
+
if (!partition) {
|
|
2514
|
+
logger.warn({ key, partitionId }, "Partition not found in map");
|
|
2515
|
+
return null;
|
|
2516
|
+
}
|
|
2517
|
+
return {
|
|
2518
|
+
nodeId: partition.ownerNodeId,
|
|
2519
|
+
partitionId,
|
|
2520
|
+
isOwner: true,
|
|
2521
|
+
isBackup: false
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Route a key and get the WebSocket connection to use
|
|
2526
|
+
*/
|
|
2527
|
+
routeToConnection(key) {
|
|
2528
|
+
const routing = this.route(key);
|
|
2529
|
+
if (!routing) {
|
|
2530
|
+
if (this.config.fallbackMode === "forward") {
|
|
2531
|
+
const primary = this.connectionPool.getAnyHealthyConnection();
|
|
2532
|
+
if (primary) {
|
|
2533
|
+
return primary;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
return null;
|
|
2537
|
+
}
|
|
2538
|
+
const socket = this.connectionPool.getConnection(routing.nodeId);
|
|
2539
|
+
if (socket) {
|
|
2540
|
+
return { nodeId: routing.nodeId, socket };
|
|
2541
|
+
}
|
|
2542
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
|
|
2543
|
+
if (partition) {
|
|
2544
|
+
for (const backupId of partition.backupNodeIds) {
|
|
2545
|
+
const backupSocket = this.connectionPool.getConnection(backupId);
|
|
2546
|
+
if (backupSocket) {
|
|
2547
|
+
logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
|
|
2548
|
+
return { nodeId: backupId, socket: backupSocket };
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
if (this.config.fallbackMode === "forward") {
|
|
2553
|
+
return this.connectionPool.getAnyHealthyConnection();
|
|
2554
|
+
}
|
|
2555
|
+
return null;
|
|
2556
|
+
}
|
|
2557
|
+
/**
|
|
2558
|
+
* Get routing info for multiple keys (batch routing)
|
|
2559
|
+
*/
|
|
2560
|
+
routeBatch(keys) {
|
|
2561
|
+
const result = /* @__PURE__ */ new Map();
|
|
2562
|
+
for (const key of keys) {
|
|
2563
|
+
const routing = this.route(key);
|
|
2564
|
+
if (routing) {
|
|
2565
|
+
const nodeId = routing.nodeId;
|
|
2566
|
+
if (!result.has(nodeId)) {
|
|
2567
|
+
result.set(nodeId, []);
|
|
2568
|
+
}
|
|
2569
|
+
result.get(nodeId).push({ ...routing, key });
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
return result;
|
|
2573
|
+
}
|
|
2574
|
+
/**
|
|
2575
|
+
* Get all partitions owned by a specific node
|
|
2576
|
+
*/
|
|
2577
|
+
getPartitionsForNode(nodeId) {
|
|
2578
|
+
if (!this.partitionMap) return [];
|
|
2579
|
+
return this.partitionMap.partitions.filter((p) => p.ownerNodeId === nodeId).map((p) => p.partitionId);
|
|
2580
|
+
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Get current partition map version
|
|
2583
|
+
*/
|
|
2584
|
+
getMapVersion() {
|
|
2585
|
+
return this.partitionMap?.version ?? 0;
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* Check if partition map is available
|
|
2589
|
+
*/
|
|
2590
|
+
hasPartitionMap() {
|
|
2591
|
+
return this.partitionMap !== null;
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Get owner node for a key.
|
|
2595
|
+
* Returns null if partition map is not available.
|
|
2596
|
+
*/
|
|
2597
|
+
getOwner(key) {
|
|
2598
|
+
if (!this.partitionMap) return null;
|
|
2599
|
+
const partitionId = this.getPartitionId(key);
|
|
2600
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
2601
|
+
return partition?.ownerNodeId ?? null;
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Get backup nodes for a key.
|
|
2605
|
+
* Returns empty array if partition map is not available.
|
|
2606
|
+
*/
|
|
2607
|
+
getBackups(key) {
|
|
2608
|
+
if (!this.partitionMap) return [];
|
|
2609
|
+
const partitionId = this.getPartitionId(key);
|
|
2610
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
2611
|
+
return partition?.backupNodeIds ?? [];
|
|
2612
|
+
}
|
|
2613
|
+
/**
|
|
2614
|
+
* Get the full partition map.
|
|
2615
|
+
* Returns null if not available.
|
|
2616
|
+
*/
|
|
2617
|
+
getMap() {
|
|
2618
|
+
return this.partitionMap;
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Update entire partition map.
|
|
2622
|
+
* Only accepts newer versions.
|
|
2623
|
+
*/
|
|
2624
|
+
updateMap(map) {
|
|
2625
|
+
if (this.partitionMap && map.version <= this.partitionMap.version) {
|
|
2626
|
+
return false;
|
|
2627
|
+
}
|
|
2628
|
+
this.partitionMap = map;
|
|
2629
|
+
this.lastRefreshTime = Date.now();
|
|
2630
|
+
this.updateConnectionPool(map);
|
|
2631
|
+
const changesCount = map.partitions.length;
|
|
2632
|
+
logger.info({
|
|
2633
|
+
version: map.version,
|
|
2634
|
+
partitions: map.partitionCount,
|
|
2635
|
+
nodes: map.nodes.length
|
|
2636
|
+
}, "Partition map updated via updateMap");
|
|
2637
|
+
this.emit("partitionMap:updated", map.version, changesCount);
|
|
2638
|
+
return true;
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Update a single partition (for delta updates).
|
|
2642
|
+
*/
|
|
2643
|
+
updatePartition(partitionId, owner, backups) {
|
|
2644
|
+
if (!this.partitionMap) return;
|
|
2645
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
2646
|
+
if (partition) {
|
|
2647
|
+
partition.ownerNodeId = owner;
|
|
2648
|
+
partition.backupNodeIds = backups;
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* Check if partition map is stale
|
|
2653
|
+
*/
|
|
2654
|
+
isMapStale() {
|
|
2655
|
+
if (!this.partitionMap) return true;
|
|
2656
|
+
const now = Date.now();
|
|
2657
|
+
return now - this.lastRefreshTime > this.config.maxMapStalenessMs;
|
|
2658
|
+
}
|
|
2659
|
+
/**
|
|
2660
|
+
* Request fresh partition map from server
|
|
2661
|
+
*/
|
|
2662
|
+
async refreshPartitionMap() {
|
|
2663
|
+
if (this.pendingRefresh) {
|
|
2664
|
+
return this.pendingRefresh;
|
|
2665
|
+
}
|
|
2666
|
+
this.pendingRefresh = this.doRefreshPartitionMap();
|
|
2667
|
+
try {
|
|
2668
|
+
await this.pendingRefresh;
|
|
2669
|
+
} finally {
|
|
2670
|
+
this.pendingRefresh = null;
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
/**
|
|
2674
|
+
* Start periodic partition map refresh
|
|
2675
|
+
*/
|
|
2676
|
+
startPeriodicRefresh() {
|
|
2677
|
+
if (this.refreshTimer) return;
|
|
2678
|
+
this.refreshTimer = setInterval(() => {
|
|
2679
|
+
if (this.isMapStale()) {
|
|
2680
|
+
this.emit("partitionMap:stale", this.getMapVersion(), this.lastRefreshTime);
|
|
2681
|
+
this.refreshPartitionMap().catch((err) => {
|
|
2682
|
+
logger.error({ error: err }, "Failed to refresh partition map");
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
2685
|
+
}, this.config.mapRefreshIntervalMs);
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Stop periodic refresh
|
|
2689
|
+
*/
|
|
2690
|
+
stopPeriodicRefresh() {
|
|
2691
|
+
if (this.refreshTimer) {
|
|
2692
|
+
clearInterval(this.refreshTimer);
|
|
2693
|
+
this.refreshTimer = null;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2697
|
+
* Handle NOT_OWNER error from server
|
|
2698
|
+
*/
|
|
2699
|
+
handleNotOwnerError(key, actualOwner, newMapVersion) {
|
|
2700
|
+
const routing = this.route(key);
|
|
2701
|
+
const expectedOwner = routing?.nodeId ?? "unknown";
|
|
2702
|
+
this.emit("routing:miss", key, expectedOwner, actualOwner);
|
|
2703
|
+
if (newMapVersion > this.getMapVersion()) {
|
|
2704
|
+
this.refreshPartitionMap().catch((err) => {
|
|
2705
|
+
logger.error({ error: err }, "Failed to refresh partition map after NOT_OWNER");
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Get statistics about routing
|
|
2711
|
+
*/
|
|
2712
|
+
getStats() {
|
|
2713
|
+
return {
|
|
2714
|
+
mapVersion: this.getMapVersion(),
|
|
2715
|
+
partitionCount: this.partitionMap?.partitionCount ?? 0,
|
|
2716
|
+
nodeCount: this.partitionMap?.nodes.length ?? 0,
|
|
2717
|
+
lastRefresh: this.lastRefreshTime,
|
|
2718
|
+
isStale: this.isMapStale()
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* Cleanup resources
|
|
2723
|
+
*/
|
|
2724
|
+
close() {
|
|
2725
|
+
this.stopPeriodicRefresh();
|
|
2726
|
+
this.partitionMap = null;
|
|
2727
|
+
}
|
|
2728
|
+
// ============================================
|
|
2729
|
+
// Private Methods
|
|
2730
|
+
// ============================================
|
|
2731
|
+
handlePartitionMap(message) {
|
|
2732
|
+
const newMap = message.payload;
|
|
2733
|
+
if (this.partitionMap && newMap.version <= this.partitionMap.version) {
|
|
2734
|
+
logger.debug({
|
|
2735
|
+
current: this.partitionMap.version,
|
|
2736
|
+
received: newMap.version
|
|
2737
|
+
}, "Ignoring older partition map");
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
this.partitionMap = newMap;
|
|
2741
|
+
this.lastRefreshTime = Date.now();
|
|
2742
|
+
this.updateConnectionPool(newMap);
|
|
2743
|
+
const changesCount = newMap.partitions.length;
|
|
2744
|
+
logger.info({
|
|
2745
|
+
version: newMap.version,
|
|
2746
|
+
partitions: newMap.partitionCount,
|
|
2747
|
+
nodes: newMap.nodes.length
|
|
2748
|
+
}, "Partition map updated");
|
|
2749
|
+
this.emit("partitionMap:updated", newMap.version, changesCount);
|
|
2750
|
+
}
|
|
2751
|
+
handlePartitionMapDelta(message) {
|
|
2752
|
+
const delta = message.payload;
|
|
2753
|
+
if (!this.partitionMap) {
|
|
2754
|
+
logger.warn("Received delta but no base map, requesting full map");
|
|
2755
|
+
this.refreshPartitionMap();
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
if (delta.previousVersion !== this.partitionMap.version) {
|
|
2759
|
+
logger.warn({
|
|
2760
|
+
expected: this.partitionMap.version,
|
|
2761
|
+
received: delta.previousVersion
|
|
2762
|
+
}, "Delta version mismatch, requesting full map");
|
|
2763
|
+
this.refreshPartitionMap();
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
for (const change of delta.changes) {
|
|
2767
|
+
this.applyPartitionChange(change);
|
|
2768
|
+
}
|
|
2769
|
+
this.partitionMap.version = delta.version;
|
|
2770
|
+
this.lastRefreshTime = Date.now();
|
|
2771
|
+
logger.info({
|
|
2772
|
+
version: delta.version,
|
|
2773
|
+
changes: delta.changes.length
|
|
2774
|
+
}, "Applied partition map delta");
|
|
2775
|
+
this.emit("partitionMap:updated", delta.version, delta.changes.length);
|
|
2776
|
+
}
|
|
2777
|
+
applyPartitionChange(change) {
|
|
2778
|
+
if (!this.partitionMap) return;
|
|
2779
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === change.partitionId);
|
|
2780
|
+
if (partition) {
|
|
2781
|
+
partition.ownerNodeId = change.newOwner;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
updateConnectionPool(map) {
|
|
2785
|
+
for (const node of map.nodes) {
|
|
2786
|
+
if (node.status === "ACTIVE" || node.status === "JOINING") {
|
|
2787
|
+
this.connectionPool.addNode(node.nodeId, node.endpoints.websocket);
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
const currentNodeIds = new Set(map.nodes.map((n) => n.nodeId));
|
|
2791
|
+
for (const nodeId of this.connectionPool.getAllNodes()) {
|
|
2792
|
+
if (!currentNodeIds.has(nodeId)) {
|
|
2793
|
+
this.connectionPool.removeNode(nodeId);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
async doRefreshPartitionMap() {
|
|
2798
|
+
logger.debug("Requesting partition map refresh");
|
|
2799
|
+
const sent = this.connectionPool.sendToPrimary({
|
|
2800
|
+
type: "PARTITION_MAP_REQUEST",
|
|
2801
|
+
payload: {
|
|
2802
|
+
currentVersion: this.getMapVersion()
|
|
2803
|
+
}
|
|
2804
|
+
});
|
|
2805
|
+
if (!sent) {
|
|
2806
|
+
throw new Error("No connection available to request partition map");
|
|
2807
|
+
}
|
|
2808
|
+
return new Promise((resolve, reject) => {
|
|
2809
|
+
const timeout = setTimeout(() => {
|
|
2810
|
+
this.removeListener("partitionMap:updated", onUpdate);
|
|
2811
|
+
reject(new Error("Partition map refresh timeout"));
|
|
2812
|
+
}, 5e3);
|
|
2813
|
+
const onUpdate = () => {
|
|
2814
|
+
clearTimeout(timeout);
|
|
2815
|
+
this.removeListener("partitionMap:updated", onUpdate);
|
|
2816
|
+
resolve();
|
|
2817
|
+
};
|
|
2818
|
+
this.once("partitionMap:updated", onUpdate);
|
|
2819
|
+
});
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
|
|
2823
|
+
// src/cluster/ClusterClient.ts
|
|
2824
|
+
var ClusterClient = class {
|
|
2825
|
+
constructor(config) {
|
|
2826
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2827
|
+
this.initialized = false;
|
|
2828
|
+
this.routingActive = false;
|
|
2829
|
+
this.routingMetrics = {
|
|
2830
|
+
directRoutes: 0,
|
|
2831
|
+
fallbackRoutes: 0,
|
|
2832
|
+
partitionMisses: 0,
|
|
2833
|
+
totalRoutes: 0
|
|
2834
|
+
};
|
|
2835
|
+
// Circuit breaker state per node
|
|
2836
|
+
this.circuits = /* @__PURE__ */ new Map();
|
|
2837
|
+
this.config = config;
|
|
2838
|
+
this.circuitBreakerConfig = {
|
|
2839
|
+
...DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
2840
|
+
...config.circuitBreaker
|
|
2841
|
+
};
|
|
2842
|
+
const poolConfig = {
|
|
2843
|
+
...DEFAULT_CONNECTION_POOL_CONFIG2,
|
|
2844
|
+
...config.connectionPool
|
|
2845
|
+
};
|
|
2846
|
+
this.connectionPool = new ConnectionPool(poolConfig);
|
|
2847
|
+
const routerConfig = {
|
|
2848
|
+
...DEFAULT_PARTITION_ROUTER_CONFIG2,
|
|
2849
|
+
fallbackMode: config.routingMode === "direct" ? "error" : "forward",
|
|
2850
|
+
...config.routing
|
|
2851
|
+
};
|
|
2852
|
+
this.partitionRouter = new PartitionRouter(this.connectionPool, routerConfig);
|
|
2853
|
+
this.setupEventHandlers();
|
|
2854
|
+
}
|
|
2855
|
+
// ============================================
|
|
2856
|
+
// Event Emitter Methods (browser-compatible)
|
|
2857
|
+
// ============================================
|
|
2858
|
+
on(event, listener) {
|
|
2859
|
+
if (!this.listeners.has(event)) {
|
|
2860
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2861
|
+
}
|
|
2862
|
+
this.listeners.get(event).add(listener);
|
|
2863
|
+
return this;
|
|
2864
|
+
}
|
|
2865
|
+
off(event, listener) {
|
|
2866
|
+
this.listeners.get(event)?.delete(listener);
|
|
2867
|
+
return this;
|
|
2868
|
+
}
|
|
2869
|
+
emit(event, ...args) {
|
|
2870
|
+
const eventListeners = this.listeners.get(event);
|
|
2871
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
2872
|
+
return false;
|
|
2873
|
+
}
|
|
2874
|
+
for (const listener of eventListeners) {
|
|
2875
|
+
try {
|
|
2876
|
+
listener(...args);
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
logger.error({ event, err }, "Error in event listener");
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
return true;
|
|
2882
|
+
}
|
|
2883
|
+
removeAllListeners(event) {
|
|
2884
|
+
if (event) {
|
|
2885
|
+
this.listeners.delete(event);
|
|
2886
|
+
} else {
|
|
2887
|
+
this.listeners.clear();
|
|
2888
|
+
}
|
|
2889
|
+
return this;
|
|
2890
|
+
}
|
|
2891
|
+
// ============================================
|
|
2892
|
+
// IConnectionProvider Implementation
|
|
2893
|
+
// ============================================
|
|
2894
|
+
/**
|
|
2895
|
+
* Connect to cluster nodes (IConnectionProvider interface).
|
|
2896
|
+
* Alias for start() method.
|
|
2897
|
+
*/
|
|
2898
|
+
async connect() {
|
|
2899
|
+
return this.start();
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* Get connection for a specific key (IConnectionProvider interface).
|
|
2903
|
+
* Routes to partition owner based on key hash when smart routing is enabled.
|
|
2904
|
+
* @throws Error if not connected
|
|
2905
|
+
*/
|
|
2906
|
+
getConnection(key) {
|
|
2907
|
+
if (!this.isConnected()) {
|
|
2908
|
+
throw new Error("ClusterClient not connected");
|
|
2909
|
+
}
|
|
2910
|
+
this.routingMetrics.totalRoutes++;
|
|
2911
|
+
if (this.config.routingMode !== "direct" || !this.routingActive) {
|
|
2912
|
+
this.routingMetrics.fallbackRoutes++;
|
|
2913
|
+
return this.getFallbackConnection();
|
|
2914
|
+
}
|
|
2915
|
+
const routing = this.partitionRouter.route(key);
|
|
2916
|
+
if (!routing) {
|
|
2917
|
+
this.routingMetrics.partitionMisses++;
|
|
2918
|
+
logger.debug({ key }, "No partition map available, using fallback");
|
|
2919
|
+
return this.getFallbackConnection();
|
|
2920
|
+
}
|
|
2921
|
+
const owner = routing.nodeId;
|
|
2922
|
+
if (!this.connectionPool.isNodeConnected(owner)) {
|
|
2923
|
+
this.routingMetrics.fallbackRoutes++;
|
|
2924
|
+
logger.debug({ key, owner }, "Partition owner not connected, using fallback");
|
|
2925
|
+
this.requestPartitionMapRefresh();
|
|
2926
|
+
return this.getFallbackConnection();
|
|
2927
|
+
}
|
|
2928
|
+
const socket = this.connectionPool.getConnection(owner);
|
|
2929
|
+
if (!socket) {
|
|
2930
|
+
this.routingMetrics.fallbackRoutes++;
|
|
2931
|
+
logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
|
|
2932
|
+
return this.getFallbackConnection();
|
|
2933
|
+
}
|
|
2934
|
+
this.routingMetrics.directRoutes++;
|
|
2935
|
+
return socket;
|
|
2936
|
+
}
|
|
2937
|
+
/**
|
|
2938
|
+
* Get fallback connection when owner is unavailable.
|
|
2939
|
+
* @throws Error if no connection available
|
|
2940
|
+
*/
|
|
2941
|
+
getFallbackConnection() {
|
|
2942
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
2943
|
+
if (!conn?.socket) {
|
|
2944
|
+
throw new Error("No healthy connection available");
|
|
2945
|
+
}
|
|
2946
|
+
return conn.socket;
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Request a partition map refresh in the background.
|
|
2950
|
+
* Called when routing to an unknown/disconnected owner.
|
|
2951
|
+
*/
|
|
2952
|
+
requestPartitionMapRefresh() {
|
|
2953
|
+
this.partitionRouter.refreshPartitionMap().catch((err) => {
|
|
2954
|
+
logger.error({ err }, "Failed to refresh partition map");
|
|
2955
|
+
});
|
|
2956
|
+
}
|
|
2957
|
+
/**
|
|
2958
|
+
* Request partition map from a specific node.
|
|
2959
|
+
* Called on first node connection.
|
|
2960
|
+
*/
|
|
2961
|
+
requestPartitionMapFromNode(nodeId) {
|
|
2962
|
+
const socket = this.connectionPool.getConnection(nodeId);
|
|
2963
|
+
if (socket) {
|
|
2964
|
+
logger.debug({ nodeId }, "Requesting partition map from node");
|
|
2965
|
+
socket.send(serialize3({
|
|
2966
|
+
type: "PARTITION_MAP_REQUEST",
|
|
2967
|
+
payload: {
|
|
2968
|
+
currentVersion: this.partitionRouter.getMapVersion()
|
|
2969
|
+
}
|
|
2970
|
+
}));
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* Check if at least one connection is active (IConnectionProvider interface).
|
|
2975
|
+
*/
|
|
2976
|
+
isConnected() {
|
|
2977
|
+
return this.connectionPool.getConnectedNodes().length > 0;
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Send data via the appropriate connection (IConnectionProvider interface).
|
|
2981
|
+
* Routes based on key if provided.
|
|
2982
|
+
*/
|
|
2983
|
+
send(data, key) {
|
|
2984
|
+
if (!this.isConnected()) {
|
|
2985
|
+
throw new Error("ClusterClient not connected");
|
|
2986
|
+
}
|
|
2987
|
+
const socket = key ? this.getConnection(key) : this.getAnyConnection();
|
|
2988
|
+
socket.send(data);
|
|
2989
|
+
}
|
|
2990
|
+
/**
|
|
2991
|
+
* Send data with automatic retry and rerouting on failure.
|
|
2992
|
+
* @param data - Data to send
|
|
2993
|
+
* @param key - Optional key for routing
|
|
2994
|
+
* @param options - Retry options
|
|
2995
|
+
* @throws Error after max retries exceeded
|
|
2996
|
+
*/
|
|
2997
|
+
async sendWithRetry(data, key, options = {}) {
|
|
2998
|
+
const {
|
|
2999
|
+
maxRetries = 3,
|
|
3000
|
+
retryDelayMs = 100,
|
|
3001
|
+
retryOnNotOwner = true
|
|
3002
|
+
} = options;
|
|
3003
|
+
let lastError = null;
|
|
3004
|
+
let nodeId = null;
|
|
3005
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
3006
|
+
try {
|
|
3007
|
+
if (key && this.routingActive) {
|
|
3008
|
+
const routing = this.partitionRouter.route(key);
|
|
3009
|
+
nodeId = routing?.nodeId ?? null;
|
|
3010
|
+
}
|
|
3011
|
+
if (nodeId && !this.canUseNode(nodeId)) {
|
|
3012
|
+
logger.debug({ nodeId, attempt }, "Circuit open, using fallback");
|
|
3013
|
+
nodeId = null;
|
|
3014
|
+
}
|
|
3015
|
+
const socket = key && nodeId ? this.connectionPool.getConnection(nodeId) : this.getAnyConnection();
|
|
3016
|
+
if (!socket) {
|
|
3017
|
+
throw new Error("No connection available");
|
|
3018
|
+
}
|
|
3019
|
+
socket.send(data);
|
|
3020
|
+
if (nodeId) {
|
|
3021
|
+
this.recordSuccess(nodeId);
|
|
3022
|
+
}
|
|
3023
|
+
return;
|
|
3024
|
+
} catch (error) {
|
|
3025
|
+
lastError = error;
|
|
3026
|
+
if (nodeId) {
|
|
3027
|
+
this.recordFailure(nodeId);
|
|
3028
|
+
}
|
|
3029
|
+
const errorCode = error?.code;
|
|
3030
|
+
if (this.isRetryableError(error)) {
|
|
3031
|
+
logger.debug(
|
|
3032
|
+
{ attempt, maxRetries, errorCode, nodeId },
|
|
3033
|
+
"Retryable error, will retry"
|
|
3034
|
+
);
|
|
3035
|
+
if (errorCode === "NOT_OWNER" && retryOnNotOwner) {
|
|
3036
|
+
await this.waitForPartitionMapUpdateInternal(2e3);
|
|
3037
|
+
} else if (errorCode === "CONNECTION_CLOSED" || !this.isConnected()) {
|
|
3038
|
+
await this.waitForConnectionInternal(5e3);
|
|
3039
|
+
}
|
|
3040
|
+
await this.delay(retryDelayMs * (attempt + 1));
|
|
3041
|
+
continue;
|
|
3042
|
+
}
|
|
3043
|
+
throw error;
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
throw new Error(
|
|
3047
|
+
`Operation failed after ${maxRetries} retries: ${lastError?.message}`
|
|
3048
|
+
);
|
|
3049
|
+
}
|
|
3050
|
+
/**
|
|
3051
|
+
* Check if an error is retryable.
|
|
3052
|
+
*/
|
|
3053
|
+
isRetryableError(error) {
|
|
3054
|
+
const code = error?.code;
|
|
3055
|
+
const message = error?.message || "";
|
|
3056
|
+
return code === "NOT_OWNER" || code === "CONNECTION_CLOSED" || code === "TIMEOUT" || code === "ECONNRESET" || message.includes("No active connections") || message.includes("No connection available") || message.includes("No healthy connection");
|
|
3057
|
+
}
|
|
3058
|
+
/**
|
|
3059
|
+
* Wait for partition map update.
|
|
3060
|
+
*/
|
|
3061
|
+
waitForPartitionMapUpdateInternal(timeoutMs) {
|
|
3062
|
+
return new Promise((resolve) => {
|
|
3063
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
3064
|
+
const handler2 = () => {
|
|
3065
|
+
clearTimeout(timeout);
|
|
3066
|
+
this.off("partitionMapUpdated", handler2);
|
|
3067
|
+
resolve();
|
|
3068
|
+
};
|
|
3069
|
+
this.on("partitionMapUpdated", handler2);
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
/**
|
|
3073
|
+
* Wait for at least one connection to be available.
|
|
3074
|
+
*/
|
|
3075
|
+
waitForConnectionInternal(timeoutMs) {
|
|
3076
|
+
return new Promise((resolve, reject) => {
|
|
3077
|
+
if (this.isConnected()) {
|
|
3078
|
+
resolve();
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
const timeout = setTimeout(() => {
|
|
3082
|
+
this.off("connected", handler2);
|
|
3083
|
+
reject(new Error("Connection timeout"));
|
|
3084
|
+
}, timeoutMs);
|
|
3085
|
+
const handler2 = () => {
|
|
3086
|
+
clearTimeout(timeout);
|
|
3087
|
+
this.off("connected", handler2);
|
|
3088
|
+
resolve();
|
|
3089
|
+
};
|
|
3090
|
+
this.on("connected", handler2);
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
/**
|
|
3094
|
+
* Helper delay function.
|
|
3095
|
+
*/
|
|
3096
|
+
delay(ms) {
|
|
3097
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3098
|
+
}
|
|
3099
|
+
// ============================================
|
|
3100
|
+
// Cluster-Specific Methods
|
|
3101
|
+
// ============================================
|
|
3102
|
+
/**
|
|
3103
|
+
* Initialize cluster connections
|
|
3104
|
+
*/
|
|
3105
|
+
async start() {
|
|
3106
|
+
if (this.initialized) return;
|
|
3107
|
+
logger.info({ seedNodes: this.config.seedNodes }, "Starting cluster client");
|
|
3108
|
+
for (let i = 0; i < this.config.seedNodes.length; i++) {
|
|
3109
|
+
const endpoint = this.config.seedNodes[i];
|
|
3110
|
+
const nodeId = `seed-${i}`;
|
|
3111
|
+
await this.connectionPool.addNode(nodeId, endpoint);
|
|
1355
3112
|
}
|
|
1356
|
-
|
|
1357
|
-
this.
|
|
3113
|
+
this.connectionPool.startHealthCheck();
|
|
3114
|
+
this.partitionRouter.startPeriodicRefresh();
|
|
3115
|
+
this.initialized = true;
|
|
3116
|
+
await this.waitForPartitionMap();
|
|
1358
3117
|
}
|
|
1359
3118
|
/**
|
|
1360
|
-
*
|
|
3119
|
+
* Set authentication token
|
|
1361
3120
|
*/
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
3121
|
+
setAuthToken(token) {
|
|
3122
|
+
this.connectionPool.setAuthToken(token);
|
|
3123
|
+
}
|
|
3124
|
+
/**
|
|
3125
|
+
* Send operation with automatic routing (legacy API for cluster operations).
|
|
3126
|
+
* @deprecated Use send(data, key) for IConnectionProvider interface
|
|
3127
|
+
*/
|
|
3128
|
+
sendMessage(key, message) {
|
|
3129
|
+
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
3130
|
+
return this.sendDirect(key, message);
|
|
1367
3131
|
}
|
|
1368
|
-
this.
|
|
3132
|
+
return this.sendForward(message);
|
|
1369
3133
|
}
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
3134
|
+
/**
|
|
3135
|
+
* Send directly to partition owner
|
|
3136
|
+
*/
|
|
3137
|
+
sendDirect(key, message) {
|
|
3138
|
+
const connection = this.partitionRouter.routeToConnection(key);
|
|
3139
|
+
if (!connection) {
|
|
3140
|
+
logger.warn({ key }, "No route available for key");
|
|
3141
|
+
return false;
|
|
1374
3142
|
}
|
|
3143
|
+
const routedMessage = {
|
|
3144
|
+
...message,
|
|
3145
|
+
_routing: {
|
|
3146
|
+
partitionId: this.partitionRouter.getPartitionId(key),
|
|
3147
|
+
mapVersion: this.partitionRouter.getMapVersion()
|
|
3148
|
+
}
|
|
3149
|
+
};
|
|
3150
|
+
connection.socket.send(serialize3(routedMessage));
|
|
3151
|
+
return true;
|
|
1375
3152
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
3153
|
+
/**
|
|
3154
|
+
* Send to primary node for server-side forwarding
|
|
3155
|
+
*/
|
|
3156
|
+
sendForward(message) {
|
|
3157
|
+
return this.connectionPool.sendToPrimary(message);
|
|
3158
|
+
}
|
|
3159
|
+
/**
|
|
3160
|
+
* Send batch of operations with routing
|
|
3161
|
+
*/
|
|
3162
|
+
sendBatch(operations) {
|
|
3163
|
+
const results = /* @__PURE__ */ new Map();
|
|
3164
|
+
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
3165
|
+
const nodeMessages = /* @__PURE__ */ new Map();
|
|
3166
|
+
for (const { key, message } of operations) {
|
|
3167
|
+
const routing = this.partitionRouter.route(key);
|
|
3168
|
+
const nodeId = routing?.nodeId ?? "primary";
|
|
3169
|
+
if (!nodeMessages.has(nodeId)) {
|
|
3170
|
+
nodeMessages.set(nodeId, []);
|
|
1387
3171
|
}
|
|
1388
|
-
|
|
3172
|
+
nodeMessages.get(nodeId).push({ key, message });
|
|
3173
|
+
}
|
|
3174
|
+
for (const [nodeId, messages] of nodeMessages) {
|
|
3175
|
+
let success;
|
|
3176
|
+
if (nodeId === "primary") {
|
|
3177
|
+
success = this.connectionPool.sendToPrimary({
|
|
3178
|
+
type: "OP_BATCH",
|
|
3179
|
+
payload: { ops: messages.map((m) => m.message) }
|
|
3180
|
+
});
|
|
3181
|
+
} else {
|
|
3182
|
+
success = this.connectionPool.send(nodeId, {
|
|
3183
|
+
type: "OP_BATCH",
|
|
3184
|
+
payload: { ops: messages.map((m) => m.message) }
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
for (const { key } of messages) {
|
|
3188
|
+
results.set(key, success);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
} else {
|
|
3192
|
+
const success = this.connectionPool.sendToPrimary({
|
|
3193
|
+
type: "OP_BATCH",
|
|
3194
|
+
payload: { ops: operations.map((o) => o.message) }
|
|
1389
3195
|
});
|
|
3196
|
+
for (const { key } of operations) {
|
|
3197
|
+
results.set(key, success);
|
|
3198
|
+
}
|
|
1390
3199
|
}
|
|
1391
3200
|
return results;
|
|
1392
3201
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
3202
|
+
/**
|
|
3203
|
+
* Get connection pool health status
|
|
3204
|
+
*/
|
|
3205
|
+
getHealthStatus() {
|
|
3206
|
+
return this.connectionPool.getHealthStatus();
|
|
1395
3207
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
3208
|
+
/**
|
|
3209
|
+
* Get partition router stats
|
|
3210
|
+
*/
|
|
3211
|
+
getRouterStats() {
|
|
3212
|
+
return this.partitionRouter.getStats();
|
|
1398
3213
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
this.fencingToken = null;
|
|
1405
|
-
this._isLocked = false;
|
|
1406
|
-
this.syncEngine = syncEngine;
|
|
1407
|
-
this.name = name;
|
|
3214
|
+
/**
|
|
3215
|
+
* Get routing metrics for monitoring smart routing effectiveness.
|
|
3216
|
+
*/
|
|
3217
|
+
getRoutingMetrics() {
|
|
3218
|
+
return { ...this.routingMetrics };
|
|
1408
3219
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Reset routing metrics counters.
|
|
3222
|
+
* Useful for monitoring intervals.
|
|
3223
|
+
*/
|
|
3224
|
+
resetRoutingMetrics() {
|
|
3225
|
+
this.routingMetrics.directRoutes = 0;
|
|
3226
|
+
this.routingMetrics.fallbackRoutes = 0;
|
|
3227
|
+
this.routingMetrics.partitionMisses = 0;
|
|
3228
|
+
this.routingMetrics.totalRoutes = 0;
|
|
1419
3229
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
} finally {
|
|
1426
|
-
this._isLocked = false;
|
|
1427
|
-
this.fencingToken = null;
|
|
1428
|
-
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Check if cluster routing is active
|
|
3232
|
+
*/
|
|
3233
|
+
isRoutingActive() {
|
|
3234
|
+
return this.routingActive;
|
|
1429
3235
|
}
|
|
1430
|
-
|
|
1431
|
-
|
|
3236
|
+
/**
|
|
3237
|
+
* Get list of connected nodes
|
|
3238
|
+
*/
|
|
3239
|
+
getConnectedNodes() {
|
|
3240
|
+
return this.connectionPool.getConnectedNodes();
|
|
1432
3241
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
this.listeners = /* @__PURE__ */ new Set();
|
|
1439
|
-
this.engine = engine;
|
|
1440
|
-
this.topic = topic;
|
|
3242
|
+
/**
|
|
3243
|
+
* Check if cluster client is initialized
|
|
3244
|
+
*/
|
|
3245
|
+
isInitialized() {
|
|
3246
|
+
return this.initialized;
|
|
1441
3247
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
3248
|
+
/**
|
|
3249
|
+
* Force refresh of partition map
|
|
3250
|
+
*/
|
|
3251
|
+
async refreshPartitionMap() {
|
|
3252
|
+
await this.partitionRouter.refreshPartitionMap();
|
|
1444
3253
|
}
|
|
1445
3254
|
/**
|
|
1446
|
-
*
|
|
3255
|
+
* Shutdown cluster client (IConnectionProvider interface).
|
|
1447
3256
|
*/
|
|
1448
|
-
|
|
1449
|
-
this.
|
|
3257
|
+
async close() {
|
|
3258
|
+
this.partitionRouter.close();
|
|
3259
|
+
this.connectionPool.close();
|
|
3260
|
+
this.initialized = false;
|
|
3261
|
+
this.routingActive = false;
|
|
3262
|
+
logger.info("Cluster client closed");
|
|
1450
3263
|
}
|
|
3264
|
+
// ============================================
|
|
3265
|
+
// Internal Access for TopGunClient
|
|
3266
|
+
// ============================================
|
|
1451
3267
|
/**
|
|
1452
|
-
*
|
|
3268
|
+
* Get the connection pool (for internal use)
|
|
1453
3269
|
*/
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
3270
|
+
getConnectionPool() {
|
|
3271
|
+
return this.connectionPool;
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Get the partition router (for internal use)
|
|
3275
|
+
*/
|
|
3276
|
+
getPartitionRouter() {
|
|
3277
|
+
return this.partitionRouter;
|
|
3278
|
+
}
|
|
3279
|
+
/**
|
|
3280
|
+
* Get any healthy WebSocket connection (IConnectionProvider interface).
|
|
3281
|
+
* @throws Error if not connected
|
|
3282
|
+
*/
|
|
3283
|
+
getAnyConnection() {
|
|
3284
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
3285
|
+
if (!conn?.socket) {
|
|
3286
|
+
throw new Error("No healthy connection available");
|
|
1457
3287
|
}
|
|
1458
|
-
|
|
1459
|
-
return () => this.unsubscribe(callback);
|
|
3288
|
+
return conn.socket;
|
|
1460
3289
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
3290
|
+
/**
|
|
3291
|
+
* Get any healthy WebSocket connection, or null if none available.
|
|
3292
|
+
* Use this for optional connection checks.
|
|
3293
|
+
*/
|
|
3294
|
+
getAnyConnectionOrNull() {
|
|
3295
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
3296
|
+
return conn?.socket ?? null;
|
|
3297
|
+
}
|
|
3298
|
+
// ============================================
|
|
3299
|
+
// Circuit Breaker Methods
|
|
3300
|
+
// ============================================
|
|
3301
|
+
/**
|
|
3302
|
+
* Get circuit breaker state for a node.
|
|
3303
|
+
*/
|
|
3304
|
+
getCircuit(nodeId) {
|
|
3305
|
+
let circuit = this.circuits.get(nodeId);
|
|
3306
|
+
if (!circuit) {
|
|
3307
|
+
circuit = { failures: 0, lastFailure: 0, state: "closed" };
|
|
3308
|
+
this.circuits.set(nodeId, circuit);
|
|
1465
3309
|
}
|
|
3310
|
+
return circuit;
|
|
1466
3311
|
}
|
|
1467
3312
|
/**
|
|
1468
|
-
*
|
|
3313
|
+
* Check if a node can be used (circuit not open).
|
|
1469
3314
|
*/
|
|
1470
|
-
|
|
1471
|
-
this.
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
3315
|
+
canUseNode(nodeId) {
|
|
3316
|
+
const circuit = this.getCircuit(nodeId);
|
|
3317
|
+
if (circuit.state === "closed") {
|
|
3318
|
+
return true;
|
|
3319
|
+
}
|
|
3320
|
+
if (circuit.state === "open") {
|
|
3321
|
+
if (Date.now() - circuit.lastFailure > this.circuitBreakerConfig.resetTimeoutMs) {
|
|
3322
|
+
circuit.state = "half-open";
|
|
3323
|
+
logger.debug({ nodeId }, "Circuit breaker half-open, allowing test request");
|
|
3324
|
+
this.emit("circuit:half-open", nodeId);
|
|
3325
|
+
return true;
|
|
3326
|
+
}
|
|
3327
|
+
return false;
|
|
3328
|
+
}
|
|
3329
|
+
return true;
|
|
3330
|
+
}
|
|
3331
|
+
/**
|
|
3332
|
+
* Record a successful operation to a node.
|
|
3333
|
+
* Resets circuit breaker on success.
|
|
3334
|
+
*/
|
|
3335
|
+
recordSuccess(nodeId) {
|
|
3336
|
+
const circuit = this.getCircuit(nodeId);
|
|
3337
|
+
const wasOpen = circuit.state !== "closed";
|
|
3338
|
+
circuit.failures = 0;
|
|
3339
|
+
circuit.state = "closed";
|
|
3340
|
+
if (wasOpen) {
|
|
3341
|
+
logger.info({ nodeId }, "Circuit breaker closed after success");
|
|
3342
|
+
this.emit("circuit:closed", nodeId);
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
/**
|
|
3346
|
+
* Record a failed operation to a node.
|
|
3347
|
+
* Opens circuit breaker after threshold failures.
|
|
3348
|
+
*/
|
|
3349
|
+
recordFailure(nodeId) {
|
|
3350
|
+
const circuit = this.getCircuit(nodeId);
|
|
3351
|
+
circuit.failures++;
|
|
3352
|
+
circuit.lastFailure = Date.now();
|
|
3353
|
+
if (circuit.failures >= this.circuitBreakerConfig.failureThreshold) {
|
|
3354
|
+
if (circuit.state !== "open") {
|
|
3355
|
+
circuit.state = "open";
|
|
3356
|
+
logger.warn({ nodeId, failures: circuit.failures }, "Circuit breaker opened");
|
|
3357
|
+
this.emit("circuit:open", nodeId);
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
/**
|
|
3362
|
+
* Get all circuit breaker states.
|
|
3363
|
+
*/
|
|
3364
|
+
getCircuitStates() {
|
|
3365
|
+
return new Map(this.circuits);
|
|
3366
|
+
}
|
|
3367
|
+
/**
|
|
3368
|
+
* Reset circuit breaker for a specific node.
|
|
3369
|
+
*/
|
|
3370
|
+
resetCircuit(nodeId) {
|
|
3371
|
+
this.circuits.delete(nodeId);
|
|
3372
|
+
logger.debug({ nodeId }, "Circuit breaker reset");
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Reset all circuit breakers.
|
|
3376
|
+
*/
|
|
3377
|
+
resetAllCircuits() {
|
|
3378
|
+
this.circuits.clear();
|
|
3379
|
+
logger.debug("All circuit breakers reset");
|
|
3380
|
+
}
|
|
3381
|
+
// ============================================
|
|
3382
|
+
// Private Methods
|
|
3383
|
+
// ============================================
|
|
3384
|
+
setupEventHandlers() {
|
|
3385
|
+
this.connectionPool.on("node:connected", (nodeId) => {
|
|
3386
|
+
logger.debug({ nodeId }, "Node connected");
|
|
3387
|
+
if (this.partitionRouter.getMapVersion() === 0) {
|
|
3388
|
+
this.requestPartitionMapFromNode(nodeId);
|
|
3389
|
+
}
|
|
3390
|
+
if (this.connectionPool.getConnectedNodes().length === 1) {
|
|
3391
|
+
this.emit("connected");
|
|
3392
|
+
}
|
|
3393
|
+
});
|
|
3394
|
+
this.connectionPool.on("node:disconnected", (nodeId, reason) => {
|
|
3395
|
+
logger.debug({ nodeId, reason }, "Node disconnected");
|
|
3396
|
+
if (this.connectionPool.getConnectedNodes().length === 0) {
|
|
3397
|
+
this.routingActive = false;
|
|
3398
|
+
this.emit("disconnected", reason);
|
|
3399
|
+
}
|
|
3400
|
+
});
|
|
3401
|
+
this.connectionPool.on("node:unhealthy", (nodeId, reason) => {
|
|
3402
|
+
logger.warn({ nodeId, reason }, "Node unhealthy");
|
|
3403
|
+
});
|
|
3404
|
+
this.connectionPool.on("error", (nodeId, error) => {
|
|
3405
|
+
this.emit("error", error);
|
|
3406
|
+
});
|
|
3407
|
+
this.connectionPool.on("message", (nodeId, data) => {
|
|
3408
|
+
this.emit("message", nodeId, data);
|
|
3409
|
+
});
|
|
3410
|
+
this.partitionRouter.on("partitionMap:updated", (version, changesCount) => {
|
|
3411
|
+
if (!this.routingActive && this.partitionRouter.hasPartitionMap()) {
|
|
3412
|
+
this.routingActive = true;
|
|
3413
|
+
logger.info({ version }, "Direct routing activated");
|
|
3414
|
+
this.emit("routing:active");
|
|
1476
3415
|
}
|
|
3416
|
+
this.emit("partitionMap:ready", version);
|
|
3417
|
+
this.emit("partitionMapUpdated");
|
|
3418
|
+
});
|
|
3419
|
+
this.partitionRouter.on("routing:miss", (key, expected, actual) => {
|
|
3420
|
+
logger.debug({ key, expected, actual }, "Routing miss detected");
|
|
3421
|
+
});
|
|
3422
|
+
}
|
|
3423
|
+
async waitForPartitionMap(timeoutMs = 1e4) {
|
|
3424
|
+
if (this.partitionRouter.hasPartitionMap()) {
|
|
3425
|
+
this.routingActive = true;
|
|
3426
|
+
return;
|
|
3427
|
+
}
|
|
3428
|
+
return new Promise((resolve) => {
|
|
3429
|
+
const timeout = setTimeout(() => {
|
|
3430
|
+
this.partitionRouter.off("partitionMap:updated", onUpdate);
|
|
3431
|
+
logger.warn("Partition map not received, using fallback routing");
|
|
3432
|
+
resolve();
|
|
3433
|
+
}, timeoutMs);
|
|
3434
|
+
const onUpdate = () => {
|
|
3435
|
+
clearTimeout(timeout);
|
|
3436
|
+
this.partitionRouter.off("partitionMap:updated", onUpdate);
|
|
3437
|
+
this.routingActive = true;
|
|
3438
|
+
resolve();
|
|
3439
|
+
};
|
|
3440
|
+
this.partitionRouter.once("partitionMap:updated", onUpdate);
|
|
1477
3441
|
});
|
|
1478
3442
|
}
|
|
1479
3443
|
};
|
|
1480
3444
|
|
|
1481
3445
|
// src/TopGunClient.ts
|
|
3446
|
+
var DEFAULT_CLUSTER_CONFIG = {
|
|
3447
|
+
connectionsPerNode: 1,
|
|
3448
|
+
smartRouting: true,
|
|
3449
|
+
partitionMapRefreshMs: 3e4,
|
|
3450
|
+
connectionTimeoutMs: 5e3,
|
|
3451
|
+
retryAttempts: 3
|
|
3452
|
+
};
|
|
1482
3453
|
var TopGunClient = class {
|
|
1483
3454
|
constructor(config) {
|
|
1484
3455
|
this.maps = /* @__PURE__ */ new Map();
|
|
1485
3456
|
this.topicHandles = /* @__PURE__ */ new Map();
|
|
3457
|
+
if (config.serverUrl && config.cluster) {
|
|
3458
|
+
throw new Error("Cannot specify both serverUrl and cluster config");
|
|
3459
|
+
}
|
|
3460
|
+
if (!config.serverUrl && !config.cluster) {
|
|
3461
|
+
throw new Error("Must specify either serverUrl or cluster config");
|
|
3462
|
+
}
|
|
1486
3463
|
this.nodeId = config.nodeId || crypto.randomUUID();
|
|
1487
3464
|
this.storageAdapter = config.storage;
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
3465
|
+
this.isClusterMode = !!config.cluster;
|
|
3466
|
+
if (config.cluster) {
|
|
3467
|
+
if (!config.cluster.seeds || config.cluster.seeds.length === 0) {
|
|
3468
|
+
throw new Error("Cluster config requires at least one seed node");
|
|
3469
|
+
}
|
|
3470
|
+
this.clusterConfig = {
|
|
3471
|
+
seeds: config.cluster.seeds,
|
|
3472
|
+
connectionsPerNode: config.cluster.connectionsPerNode ?? DEFAULT_CLUSTER_CONFIG.connectionsPerNode,
|
|
3473
|
+
smartRouting: config.cluster.smartRouting ?? DEFAULT_CLUSTER_CONFIG.smartRouting,
|
|
3474
|
+
partitionMapRefreshMs: config.cluster.partitionMapRefreshMs ?? DEFAULT_CLUSTER_CONFIG.partitionMapRefreshMs,
|
|
3475
|
+
connectionTimeoutMs: config.cluster.connectionTimeoutMs ?? DEFAULT_CLUSTER_CONFIG.connectionTimeoutMs,
|
|
3476
|
+
retryAttempts: config.cluster.retryAttempts ?? DEFAULT_CLUSTER_CONFIG.retryAttempts
|
|
3477
|
+
};
|
|
3478
|
+
this.clusterClient = new ClusterClient({
|
|
3479
|
+
enabled: true,
|
|
3480
|
+
seedNodes: this.clusterConfig.seeds,
|
|
3481
|
+
routingMode: this.clusterConfig.smartRouting ? "direct" : "forward",
|
|
3482
|
+
connectionPool: {
|
|
3483
|
+
maxConnectionsPerNode: this.clusterConfig.connectionsPerNode,
|
|
3484
|
+
connectionTimeoutMs: this.clusterConfig.connectionTimeoutMs
|
|
3485
|
+
},
|
|
3486
|
+
routing: {
|
|
3487
|
+
mapRefreshIntervalMs: this.clusterConfig.partitionMapRefreshMs
|
|
3488
|
+
}
|
|
3489
|
+
});
|
|
3490
|
+
this.syncEngine = new SyncEngine({
|
|
3491
|
+
nodeId: this.nodeId,
|
|
3492
|
+
connectionProvider: this.clusterClient,
|
|
3493
|
+
storageAdapter: this.storageAdapter,
|
|
3494
|
+
backoff: config.backoff,
|
|
3495
|
+
backpressure: config.backpressure
|
|
3496
|
+
});
|
|
3497
|
+
logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
|
|
3498
|
+
} else {
|
|
3499
|
+
this.syncEngine = new SyncEngine({
|
|
3500
|
+
nodeId: this.nodeId,
|
|
3501
|
+
serverUrl: config.serverUrl,
|
|
3502
|
+
storageAdapter: this.storageAdapter,
|
|
3503
|
+
backoff: config.backoff,
|
|
3504
|
+
backpressure: config.backpressure
|
|
3505
|
+
});
|
|
3506
|
+
logger.info({ serverUrl: config.serverUrl }, "TopGunClient initialized in single-server mode");
|
|
3507
|
+
}
|
|
1496
3508
|
}
|
|
1497
3509
|
async start() {
|
|
1498
3510
|
await this.storageAdapter.initialize("topgun_offline_db");
|
|
@@ -1652,9 +3664,69 @@ var TopGunClient = class {
|
|
|
1652
3664
|
* Closes the client, disconnecting from the server and cleaning up resources.
|
|
1653
3665
|
*/
|
|
1654
3666
|
close() {
|
|
3667
|
+
if (this.clusterClient) {
|
|
3668
|
+
this.clusterClient.close();
|
|
3669
|
+
}
|
|
1655
3670
|
this.syncEngine.close();
|
|
1656
3671
|
}
|
|
1657
3672
|
// ============================================
|
|
3673
|
+
// Cluster Mode API
|
|
3674
|
+
// ============================================
|
|
3675
|
+
/**
|
|
3676
|
+
* Check if running in cluster mode
|
|
3677
|
+
*/
|
|
3678
|
+
isCluster() {
|
|
3679
|
+
return this.isClusterMode;
|
|
3680
|
+
}
|
|
3681
|
+
/**
|
|
3682
|
+
* Get list of connected cluster nodes (cluster mode only)
|
|
3683
|
+
* @returns Array of connected node IDs, or empty array in single-server mode
|
|
3684
|
+
*/
|
|
3685
|
+
getConnectedNodes() {
|
|
3686
|
+
if (!this.clusterClient) return [];
|
|
3687
|
+
return this.clusterClient.getConnectedNodes();
|
|
3688
|
+
}
|
|
3689
|
+
/**
|
|
3690
|
+
* Get the current partition map version (cluster mode only)
|
|
3691
|
+
* @returns Partition map version, or 0 in single-server mode
|
|
3692
|
+
*/
|
|
3693
|
+
getPartitionMapVersion() {
|
|
3694
|
+
if (!this.clusterClient) return 0;
|
|
3695
|
+
return this.clusterClient.getRouterStats().mapVersion;
|
|
3696
|
+
}
|
|
3697
|
+
/**
|
|
3698
|
+
* Check if direct routing is active (cluster mode only)
|
|
3699
|
+
* Direct routing sends operations directly to partition owners.
|
|
3700
|
+
* @returns true if routing is active, false otherwise
|
|
3701
|
+
*/
|
|
3702
|
+
isRoutingActive() {
|
|
3703
|
+
if (!this.clusterClient) return false;
|
|
3704
|
+
return this.clusterClient.isRoutingActive();
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Get health status for all cluster nodes (cluster mode only)
|
|
3708
|
+
* @returns Map of node IDs to their health status
|
|
3709
|
+
*/
|
|
3710
|
+
getClusterHealth() {
|
|
3711
|
+
if (!this.clusterClient) return /* @__PURE__ */ new Map();
|
|
3712
|
+
return this.clusterClient.getHealthStatus();
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* Force refresh of partition map (cluster mode only)
|
|
3716
|
+
* Use this after detecting routing errors.
|
|
3717
|
+
*/
|
|
3718
|
+
async refreshPartitionMap() {
|
|
3719
|
+
if (!this.clusterClient) return;
|
|
3720
|
+
await this.clusterClient.refreshPartitionMap();
|
|
3721
|
+
}
|
|
3722
|
+
/**
|
|
3723
|
+
* Get cluster router statistics (cluster mode only)
|
|
3724
|
+
*/
|
|
3725
|
+
getClusterStats() {
|
|
3726
|
+
if (!this.clusterClient) return null;
|
|
3727
|
+
return this.clusterClient.getRouterStats();
|
|
3728
|
+
}
|
|
3729
|
+
// ============================================
|
|
1658
3730
|
// Connection State API
|
|
1659
3731
|
// ============================================
|
|
1660
3732
|
/**
|
|
@@ -2005,14 +4077,14 @@ var CollectionWrapper = class {
|
|
|
2005
4077
|
};
|
|
2006
4078
|
|
|
2007
4079
|
// src/crypto/EncryptionManager.ts
|
|
2008
|
-
import { serialize as
|
|
4080
|
+
import { serialize as serialize4, deserialize as deserialize3 } from "@topgunbuild/core";
|
|
2009
4081
|
var _EncryptionManager = class _EncryptionManager {
|
|
2010
4082
|
/**
|
|
2011
4083
|
* Encrypts data using AES-GCM.
|
|
2012
4084
|
* Serializes data to MessagePack before encryption.
|
|
2013
4085
|
*/
|
|
2014
4086
|
static async encrypt(key, data) {
|
|
2015
|
-
const encoded =
|
|
4087
|
+
const encoded = serialize4(data);
|
|
2016
4088
|
const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
|
|
2017
4089
|
const ciphertext = await window.crypto.subtle.encrypt(
|
|
2018
4090
|
{
|
|
@@ -2041,7 +4113,7 @@ var _EncryptionManager = class _EncryptionManager {
|
|
|
2041
4113
|
key,
|
|
2042
4114
|
record.data
|
|
2043
4115
|
);
|
|
2044
|
-
return
|
|
4116
|
+
return deserialize3(new Uint8Array(plaintextBuffer));
|
|
2045
4117
|
} catch (err) {
|
|
2046
4118
|
console.error("Decryption failed", err);
|
|
2047
4119
|
throw new Error("Failed to decrypt data: " + err);
|
|
@@ -2168,12 +4240,17 @@ var EncryptedStorageAdapter = class {
|
|
|
2168
4240
|
import { LWWMap as LWWMap3, Predicates } from "@topgunbuild/core";
|
|
2169
4241
|
export {
|
|
2170
4242
|
BackpressureError,
|
|
4243
|
+
ClusterClient,
|
|
4244
|
+
ConnectionPool,
|
|
2171
4245
|
DEFAULT_BACKPRESSURE_CONFIG,
|
|
4246
|
+
DEFAULT_CLUSTER_CONFIG,
|
|
2172
4247
|
EncryptedStorageAdapter,
|
|
2173
4248
|
IDBAdapter,
|
|
2174
4249
|
LWWMap3 as LWWMap,
|
|
4250
|
+
PartitionRouter,
|
|
2175
4251
|
Predicates,
|
|
2176
4252
|
QueryHandle,
|
|
4253
|
+
SingleServerProvider,
|
|
2177
4254
|
SyncEngine,
|
|
2178
4255
|
SyncState,
|
|
2179
4256
|
SyncStateMachine,
|