@topgunbuild/client 0.2.1 → 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 +814 -10
- package/dist/index.d.ts +814 -10
- package/dist/index.js +2048 -79
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2045 -70
- 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,
|
|
@@ -234,8 +461,11 @@ var SyncEngine = class {
|
|
|
234
461
|
this.backpressureListeners = /* @__PURE__ */ new Map();
|
|
235
462
|
// Write Concern state (Phase 5.01)
|
|
236
463
|
this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
|
|
464
|
+
if (!config.serverUrl && !config.connectionProvider) {
|
|
465
|
+
throw new Error("SyncEngine requires either serverUrl or connectionProvider");
|
|
466
|
+
}
|
|
237
467
|
this.nodeId = config.nodeId;
|
|
238
|
-
this.serverUrl = config.serverUrl;
|
|
468
|
+
this.serverUrl = config.serverUrl || "";
|
|
239
469
|
this.storageAdapter = config.storageAdapter;
|
|
240
470
|
this.hlc = new HLC(this.nodeId);
|
|
241
471
|
this.stateMachine = new SyncStateMachine();
|
|
@@ -252,7 +482,15 @@ var SyncEngine = class {
|
|
|
252
482
|
...DEFAULT_BACKPRESSURE_CONFIG,
|
|
253
483
|
...config.backpressure
|
|
254
484
|
};
|
|
255
|
-
|
|
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
|
+
}
|
|
256
494
|
this.loadOpLog();
|
|
257
495
|
}
|
|
258
496
|
// ============================================
|
|
@@ -303,6 +541,65 @@ var SyncEngine = class {
|
|
|
303
541
|
// ============================================
|
|
304
542
|
// Connection Management
|
|
305
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
|
+
*/
|
|
306
603
|
initConnection() {
|
|
307
604
|
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
308
605
|
this.websocket = new WebSocket(this.serverUrl);
|
|
@@ -378,6 +675,40 @@ var SyncEngine = class {
|
|
|
378
675
|
resetBackoff() {
|
|
379
676
|
this.backoffAttempt = 0;
|
|
380
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
|
+
}
|
|
381
712
|
async loadOpLog() {
|
|
382
713
|
const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
|
|
383
714
|
if (storedTimestamp) {
|
|
@@ -424,36 +755,34 @@ var SyncEngine = class {
|
|
|
424
755
|
const pending = this.opLog.filter((op) => !op.synced);
|
|
425
756
|
if (pending.length === 0) return;
|
|
426
757
|
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}));
|
|
434
|
-
}
|
|
758
|
+
this.sendMessage({
|
|
759
|
+
type: "OP_BATCH",
|
|
760
|
+
payload: {
|
|
761
|
+
ops: pending
|
|
762
|
+
}
|
|
763
|
+
});
|
|
435
764
|
}
|
|
436
765
|
startMerkleSync() {
|
|
437
766
|
for (const [mapName, map] of this.maps) {
|
|
438
767
|
if (map instanceof LWWMap) {
|
|
439
768
|
logger.info({ mapName }, "Starting Merkle sync for LWWMap");
|
|
440
|
-
this.
|
|
769
|
+
this.sendMessage({
|
|
441
770
|
type: "SYNC_INIT",
|
|
442
771
|
mapName,
|
|
443
772
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
444
|
-
})
|
|
773
|
+
});
|
|
445
774
|
} else if (map instanceof ORMap) {
|
|
446
775
|
logger.info({ mapName }, "Starting Merkle sync for ORMap");
|
|
447
776
|
const tree = map.getMerkleTree();
|
|
448
777
|
const rootHash = tree.getRootHash();
|
|
449
778
|
const bucketHashes = tree.getBuckets("");
|
|
450
|
-
this.
|
|
779
|
+
this.sendMessage({
|
|
451
780
|
type: "ORMAP_SYNC_INIT",
|
|
452
781
|
mapName,
|
|
453
782
|
rootHash,
|
|
454
783
|
bucketHashes,
|
|
455
784
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
456
|
-
})
|
|
785
|
+
});
|
|
457
786
|
}
|
|
458
787
|
}
|
|
459
788
|
}
|
|
@@ -494,10 +823,10 @@ var SyncEngine = class {
|
|
|
494
823
|
}
|
|
495
824
|
const token = this.authToken;
|
|
496
825
|
if (!token) return;
|
|
497
|
-
this.
|
|
826
|
+
this.sendMessage({
|
|
498
827
|
type: "AUTH",
|
|
499
828
|
token
|
|
500
|
-
})
|
|
829
|
+
});
|
|
501
830
|
}
|
|
502
831
|
subscribeToQuery(query) {
|
|
503
832
|
this.queries.set(query.id, query);
|
|
@@ -514,27 +843,27 @@ var SyncEngine = class {
|
|
|
514
843
|
unsubscribeFromTopic(topic) {
|
|
515
844
|
this.topics.delete(topic);
|
|
516
845
|
if (this.isAuthenticated()) {
|
|
517
|
-
this.
|
|
846
|
+
this.sendMessage({
|
|
518
847
|
type: "TOPIC_UNSUB",
|
|
519
848
|
payload: { topic }
|
|
520
|
-
})
|
|
849
|
+
});
|
|
521
850
|
}
|
|
522
851
|
}
|
|
523
852
|
publishTopic(topic, data) {
|
|
524
853
|
if (this.isAuthenticated()) {
|
|
525
|
-
this.
|
|
854
|
+
this.sendMessage({
|
|
526
855
|
type: "TOPIC_PUB",
|
|
527
856
|
payload: { topic, data }
|
|
528
|
-
})
|
|
857
|
+
});
|
|
529
858
|
} else {
|
|
530
859
|
logger.warn({ topic }, "Dropped topic publish (offline)");
|
|
531
860
|
}
|
|
532
861
|
}
|
|
533
862
|
sendTopicSubscription(topic) {
|
|
534
|
-
this.
|
|
863
|
+
this.sendMessage({
|
|
535
864
|
type: "TOPIC_SUB",
|
|
536
865
|
payload: { topic }
|
|
537
|
-
})
|
|
866
|
+
});
|
|
538
867
|
}
|
|
539
868
|
/**
|
|
540
869
|
* Executes a query against local storage immediately
|
|
@@ -571,21 +900,21 @@ var SyncEngine = class {
|
|
|
571
900
|
unsubscribeFromQuery(queryId) {
|
|
572
901
|
this.queries.delete(queryId);
|
|
573
902
|
if (this.isAuthenticated()) {
|
|
574
|
-
this.
|
|
903
|
+
this.sendMessage({
|
|
575
904
|
type: "QUERY_UNSUB",
|
|
576
905
|
payload: { queryId }
|
|
577
|
-
})
|
|
906
|
+
});
|
|
578
907
|
}
|
|
579
908
|
}
|
|
580
909
|
sendQuerySubscription(query) {
|
|
581
|
-
this.
|
|
910
|
+
this.sendMessage({
|
|
582
911
|
type: "QUERY_SUB",
|
|
583
912
|
payload: {
|
|
584
913
|
queryId: query.id,
|
|
585
914
|
mapName: query.getMapName(),
|
|
586
915
|
query: query.getFilter()
|
|
587
916
|
}
|
|
588
|
-
})
|
|
917
|
+
});
|
|
589
918
|
}
|
|
590
919
|
requestLock(name, requestId, ttl) {
|
|
591
920
|
if (!this.isAuthenticated()) {
|
|
@@ -600,10 +929,15 @@ var SyncEngine = class {
|
|
|
600
929
|
}, 3e4);
|
|
601
930
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
602
931
|
try {
|
|
603
|
-
this.
|
|
932
|
+
const sent = this.sendMessage({
|
|
604
933
|
type: "LOCK_REQUEST",
|
|
605
934
|
payload: { requestId, name, ttl }
|
|
606
|
-
})
|
|
935
|
+
});
|
|
936
|
+
if (!sent) {
|
|
937
|
+
clearTimeout(timer);
|
|
938
|
+
this.pendingLockRequests.delete(requestId);
|
|
939
|
+
reject(new Error("Failed to send lock request"));
|
|
940
|
+
}
|
|
607
941
|
} catch (e) {
|
|
608
942
|
clearTimeout(timer);
|
|
609
943
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -622,10 +956,15 @@ var SyncEngine = class {
|
|
|
622
956
|
}, 5e3);
|
|
623
957
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
624
958
|
try {
|
|
625
|
-
this.
|
|
959
|
+
const sent = this.sendMessage({
|
|
626
960
|
type: "LOCK_RELEASE",
|
|
627
961
|
payload: { requestId, name, fencingToken }
|
|
628
|
-
})
|
|
962
|
+
});
|
|
963
|
+
if (!sent) {
|
|
964
|
+
clearTimeout(timer);
|
|
965
|
+
this.pendingLockRequests.delete(requestId);
|
|
966
|
+
resolve(false);
|
|
967
|
+
}
|
|
629
968
|
} catch (e) {
|
|
630
969
|
clearTimeout(timer);
|
|
631
970
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -804,11 +1143,11 @@ var SyncEngine = class {
|
|
|
804
1143
|
const { mapName } = message.payload;
|
|
805
1144
|
logger.warn({ mapName }, "Sync Reset Required due to GC Age");
|
|
806
1145
|
await this.resetMap(mapName);
|
|
807
|
-
this.
|
|
1146
|
+
this.sendMessage({
|
|
808
1147
|
type: "SYNC_INIT",
|
|
809
1148
|
mapName,
|
|
810
1149
|
lastSyncTimestamp: 0
|
|
811
|
-
})
|
|
1150
|
+
});
|
|
812
1151
|
break;
|
|
813
1152
|
}
|
|
814
1153
|
case "SYNC_RESP_ROOT": {
|
|
@@ -818,10 +1157,10 @@ var SyncEngine = class {
|
|
|
818
1157
|
const localRootHash = map.getMerkleTree().getRootHash();
|
|
819
1158
|
if (localRootHash !== rootHash) {
|
|
820
1159
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
|
|
821
|
-
this.
|
|
1160
|
+
this.sendMessage({
|
|
822
1161
|
type: "MERKLE_REQ_BUCKET",
|
|
823
1162
|
payload: { mapName, path: "" }
|
|
824
|
-
})
|
|
1163
|
+
});
|
|
825
1164
|
} else {
|
|
826
1165
|
logger.info({ mapName }, "Map is in sync");
|
|
827
1166
|
}
|
|
@@ -843,10 +1182,10 @@ var SyncEngine = class {
|
|
|
843
1182
|
const localHash = localBuckets[bucketKey] || 0;
|
|
844
1183
|
if (localHash !== remoteHash) {
|
|
845
1184
|
const newPath = path + bucketKey;
|
|
846
|
-
this.
|
|
1185
|
+
this.sendMessage({
|
|
847
1186
|
type: "MERKLE_REQ_BUCKET",
|
|
848
1187
|
payload: { mapName, path: newPath }
|
|
849
|
-
})
|
|
1188
|
+
});
|
|
850
1189
|
}
|
|
851
1190
|
}
|
|
852
1191
|
}
|
|
@@ -879,10 +1218,10 @@ var SyncEngine = class {
|
|
|
879
1218
|
const localRootHash = localTree.getRootHash();
|
|
880
1219
|
if (localRootHash !== rootHash) {
|
|
881
1220
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
|
|
882
|
-
this.
|
|
1221
|
+
this.sendMessage({
|
|
883
1222
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
884
1223
|
payload: { mapName, path: "" }
|
|
885
|
-
})
|
|
1224
|
+
});
|
|
886
1225
|
} else {
|
|
887
1226
|
logger.info({ mapName }, "ORMap is in sync");
|
|
888
1227
|
}
|
|
@@ -904,10 +1243,10 @@ var SyncEngine = class {
|
|
|
904
1243
|
const localHash = localBuckets[bucketKey] || 0;
|
|
905
1244
|
if (localHash !== remoteHash) {
|
|
906
1245
|
const newPath = path + bucketKey;
|
|
907
|
-
this.
|
|
1246
|
+
this.sendMessage({
|
|
908
1247
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
909
1248
|
payload: { mapName, path: newPath }
|
|
910
|
-
})
|
|
1249
|
+
});
|
|
911
1250
|
}
|
|
912
1251
|
}
|
|
913
1252
|
for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
|
|
@@ -998,7 +1337,11 @@ var SyncEngine = class {
|
|
|
998
1337
|
clearTimeout(this.reconnectTimer);
|
|
999
1338
|
this.reconnectTimer = null;
|
|
1000
1339
|
}
|
|
1001
|
-
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) {
|
|
1002
1345
|
this.websocket.onclose = null;
|
|
1003
1346
|
this.websocket.close();
|
|
1004
1347
|
this.websocket = null;
|
|
@@ -1015,7 +1358,100 @@ var SyncEngine = class {
|
|
|
1015
1358
|
this.close();
|
|
1016
1359
|
this.stateMachine.reset();
|
|
1017
1360
|
this.resetBackoff();
|
|
1018
|
-
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;
|
|
1019
1455
|
}
|
|
1020
1456
|
async resetMap(mapName) {
|
|
1021
1457
|
const map = this.maps.get(mapName);
|
|
@@ -1063,12 +1499,12 @@ var SyncEngine = class {
|
|
|
1063
1499
|
* Sends a PING message to the server.
|
|
1064
1500
|
*/
|
|
1065
1501
|
sendPing() {
|
|
1066
|
-
if (this.
|
|
1502
|
+
if (this.canSend()) {
|
|
1067
1503
|
const pingMessage = {
|
|
1068
1504
|
type: "PING",
|
|
1069
1505
|
timestamp: Date.now()
|
|
1070
1506
|
};
|
|
1071
|
-
this.
|
|
1507
|
+
this.sendMessage(pingMessage);
|
|
1072
1508
|
}
|
|
1073
1509
|
}
|
|
1074
1510
|
/**
|
|
@@ -1147,13 +1583,13 @@ var SyncEngine = class {
|
|
|
1147
1583
|
}
|
|
1148
1584
|
}
|
|
1149
1585
|
if (entries.length > 0) {
|
|
1150
|
-
this.
|
|
1586
|
+
this.sendMessage({
|
|
1151
1587
|
type: "ORMAP_PUSH_DIFF",
|
|
1152
1588
|
payload: {
|
|
1153
1589
|
mapName,
|
|
1154
1590
|
entries
|
|
1155
1591
|
}
|
|
1156
|
-
})
|
|
1592
|
+
});
|
|
1157
1593
|
logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
|
|
1158
1594
|
}
|
|
1159
1595
|
}
|
|
@@ -1580,33 +2016,1507 @@ var TopicHandle = class {
|
|
|
1580
2016
|
}
|
|
1581
2017
|
};
|
|
1582
2018
|
|
|
1583
|
-
// src/
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
|
1596
2042
|
};
|
|
1597
|
-
this.syncEngine = new SyncEngine(syncEngineConfig);
|
|
1598
2043
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
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;
|
|
1601
2053
|
}
|
|
1602
|
-
|
|
1603
|
-
this.
|
|
2054
|
+
off(event, listener) {
|
|
2055
|
+
this.listeners.get(event)?.delete(listener);
|
|
2056
|
+
return this;
|
|
1604
2057
|
}
|
|
1605
|
-
|
|
1606
|
-
this.
|
|
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;
|
|
1607
2079
|
}
|
|
1608
2080
|
/**
|
|
1609
|
-
*
|
|
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);
|
|
3112
|
+
}
|
|
3113
|
+
this.connectionPool.startHealthCheck();
|
|
3114
|
+
this.partitionRouter.startPeriodicRefresh();
|
|
3115
|
+
this.initialized = true;
|
|
3116
|
+
await this.waitForPartitionMap();
|
|
3117
|
+
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Set authentication token
|
|
3120
|
+
*/
|
|
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);
|
|
3131
|
+
}
|
|
3132
|
+
return this.sendForward(message);
|
|
3133
|
+
}
|
|
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;
|
|
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;
|
|
3152
|
+
}
|
|
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, []);
|
|
3171
|
+
}
|
|
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) }
|
|
3195
|
+
});
|
|
3196
|
+
for (const { key } of operations) {
|
|
3197
|
+
results.set(key, success);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
return results;
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* Get connection pool health status
|
|
3204
|
+
*/
|
|
3205
|
+
getHealthStatus() {
|
|
3206
|
+
return this.connectionPool.getHealthStatus();
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* Get partition router stats
|
|
3210
|
+
*/
|
|
3211
|
+
getRouterStats() {
|
|
3212
|
+
return this.partitionRouter.getStats();
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* Get routing metrics for monitoring smart routing effectiveness.
|
|
3216
|
+
*/
|
|
3217
|
+
getRoutingMetrics() {
|
|
3218
|
+
return { ...this.routingMetrics };
|
|
3219
|
+
}
|
|
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;
|
|
3229
|
+
}
|
|
3230
|
+
/**
|
|
3231
|
+
* Check if cluster routing is active
|
|
3232
|
+
*/
|
|
3233
|
+
isRoutingActive() {
|
|
3234
|
+
return this.routingActive;
|
|
3235
|
+
}
|
|
3236
|
+
/**
|
|
3237
|
+
* Get list of connected nodes
|
|
3238
|
+
*/
|
|
3239
|
+
getConnectedNodes() {
|
|
3240
|
+
return this.connectionPool.getConnectedNodes();
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Check if cluster client is initialized
|
|
3244
|
+
*/
|
|
3245
|
+
isInitialized() {
|
|
3246
|
+
return this.initialized;
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Force refresh of partition map
|
|
3250
|
+
*/
|
|
3251
|
+
async refreshPartitionMap() {
|
|
3252
|
+
await this.partitionRouter.refreshPartitionMap();
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* Shutdown cluster client (IConnectionProvider interface).
|
|
3256
|
+
*/
|
|
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");
|
|
3263
|
+
}
|
|
3264
|
+
// ============================================
|
|
3265
|
+
// Internal Access for TopGunClient
|
|
3266
|
+
// ============================================
|
|
3267
|
+
/**
|
|
3268
|
+
* Get the connection pool (for internal use)
|
|
3269
|
+
*/
|
|
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");
|
|
3287
|
+
}
|
|
3288
|
+
return conn.socket;
|
|
3289
|
+
}
|
|
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);
|
|
3309
|
+
}
|
|
3310
|
+
return circuit;
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3313
|
+
* Check if a node can be used (circuit not open).
|
|
3314
|
+
*/
|
|
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");
|
|
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);
|
|
3441
|
+
});
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
|
|
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
|
+
};
|
|
3453
|
+
var TopGunClient = class {
|
|
3454
|
+
constructor(config) {
|
|
3455
|
+
this.maps = /* @__PURE__ */ new Map();
|
|
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
|
+
}
|
|
3463
|
+
this.nodeId = config.nodeId || crypto.randomUUID();
|
|
3464
|
+
this.storageAdapter = config.storage;
|
|
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
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
async start() {
|
|
3510
|
+
await this.storageAdapter.initialize("topgun_offline_db");
|
|
3511
|
+
}
|
|
3512
|
+
setAuthToken(token) {
|
|
3513
|
+
this.syncEngine.setAuthToken(token);
|
|
3514
|
+
}
|
|
3515
|
+
setAuthTokenProvider(provider) {
|
|
3516
|
+
this.syncEngine.setTokenProvider(provider);
|
|
3517
|
+
}
|
|
3518
|
+
/**
|
|
3519
|
+
* Creates a live query subscription for a map.
|
|
1610
3520
|
*/
|
|
1611
3521
|
query(mapName, filter) {
|
|
1612
3522
|
return new QueryHandle(this.syncEngine, mapName, filter);
|
|
@@ -1754,9 +3664,69 @@ var TopGunClient = class {
|
|
|
1754
3664
|
* Closes the client, disconnecting from the server and cleaning up resources.
|
|
1755
3665
|
*/
|
|
1756
3666
|
close() {
|
|
3667
|
+
if (this.clusterClient) {
|
|
3668
|
+
this.clusterClient.close();
|
|
3669
|
+
}
|
|
1757
3670
|
this.syncEngine.close();
|
|
1758
3671
|
}
|
|
1759
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
|
+
// ============================================
|
|
1760
3730
|
// Connection State API
|
|
1761
3731
|
// ============================================
|
|
1762
3732
|
/**
|
|
@@ -2107,14 +4077,14 @@ var CollectionWrapper = class {
|
|
|
2107
4077
|
};
|
|
2108
4078
|
|
|
2109
4079
|
// src/crypto/EncryptionManager.ts
|
|
2110
|
-
import { serialize as
|
|
4080
|
+
import { serialize as serialize4, deserialize as deserialize3 } from "@topgunbuild/core";
|
|
2111
4081
|
var _EncryptionManager = class _EncryptionManager {
|
|
2112
4082
|
/**
|
|
2113
4083
|
* Encrypts data using AES-GCM.
|
|
2114
4084
|
* Serializes data to MessagePack before encryption.
|
|
2115
4085
|
*/
|
|
2116
4086
|
static async encrypt(key, data) {
|
|
2117
|
-
const encoded =
|
|
4087
|
+
const encoded = serialize4(data);
|
|
2118
4088
|
const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
|
|
2119
4089
|
const ciphertext = await window.crypto.subtle.encrypt(
|
|
2120
4090
|
{
|
|
@@ -2143,7 +4113,7 @@ var _EncryptionManager = class _EncryptionManager {
|
|
|
2143
4113
|
key,
|
|
2144
4114
|
record.data
|
|
2145
4115
|
);
|
|
2146
|
-
return
|
|
4116
|
+
return deserialize3(new Uint8Array(plaintextBuffer));
|
|
2147
4117
|
} catch (err) {
|
|
2148
4118
|
console.error("Decryption failed", err);
|
|
2149
4119
|
throw new Error("Failed to decrypt data: " + err);
|
|
@@ -2270,12 +4240,17 @@ var EncryptedStorageAdapter = class {
|
|
|
2270
4240
|
import { LWWMap as LWWMap3, Predicates } from "@topgunbuild/core";
|
|
2271
4241
|
export {
|
|
2272
4242
|
BackpressureError,
|
|
4243
|
+
ClusterClient,
|
|
4244
|
+
ConnectionPool,
|
|
2273
4245
|
DEFAULT_BACKPRESSURE_CONFIG,
|
|
4246
|
+
DEFAULT_CLUSTER_CONFIG,
|
|
2274
4247
|
EncryptedStorageAdapter,
|
|
2275
4248
|
IDBAdapter,
|
|
2276
4249
|
LWWMap3 as LWWMap,
|
|
4250
|
+
PartitionRouter,
|
|
2277
4251
|
Predicates,
|
|
2278
4252
|
QueryHandle,
|
|
4253
|
+
SingleServerProvider,
|
|
2279
4254
|
SyncEngine,
|
|
2280
4255
|
SyncState,
|
|
2281
4256
|
SyncStateMachine,
|