svelte-adapter-uws-extensions 0.5.9 → 0.5.10
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/package.json +1 -1
- package/redis/cursor.js +94 -2
package/package.json
CHANGED
package/redis/cursor.js
CHANGED
|
@@ -233,6 +233,13 @@ export function createCursor(client, options = {}) {
|
|
|
233
233
|
enqueueInbound(parsed.topic, entry.key, entry.data, activePlatform);
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
|
+
} else if (parsed.event === EVENTS.REMOVE && parsed.payload && typeof parsed.payload.key === 'string') {
|
|
237
|
+
// Peer-relayed cursor removes are coalesced through the
|
|
238
|
+
// same tick buffer the local close path uses. Without
|
|
239
|
+
// this, a mass-disconnect on one instance produces an
|
|
240
|
+
// equally-sized O(N) immediate-publish storm on every
|
|
241
|
+
// other instance, propagating the OOM risk cluster-wide.
|
|
242
|
+
queueRemove(parsed.topic, parsed.payload.key, activePlatform);
|
|
236
243
|
} else {
|
|
237
244
|
activePlatform.publish(
|
|
238
245
|
'__cursor:' + parsed.topic,
|
|
@@ -443,6 +450,75 @@ export function createCursor(client, options = {}) {
|
|
|
443
450
|
let driftMax = 0;
|
|
444
451
|
let flushCount = 0;
|
|
445
452
|
|
|
453
|
+
// Pending REMOVE keys per topic, coalesced into one wire frame per
|
|
454
|
+
// subscriber per event-loop iteration. Mass-disconnect scenarios (e.g.
|
|
455
|
+
// 5K cursors closing in one tick during a stress test or browser-tab
|
|
456
|
+
// teardown of a packed board) used to fire 5K immediate publishes per
|
|
457
|
+
// topic, each fanning out to every remaining subscriber. The resulting
|
|
458
|
+
// O(N^2) uWS send queue allocations OOM-killed workers ~18s into the
|
|
459
|
+
// cleanup cascade. With this buffer, every subscriber sees one frame
|
|
460
|
+
// per topic per tick containing the full leave list, regardless of
|
|
461
|
+
// how many sockets dropped together. Symmetric to the presence
|
|
462
|
+
// `pendingDiffs` buffer.
|
|
463
|
+
//
|
|
464
|
+
// Why `setTimeout(0)` over `queueMicrotask`: uWS dispatches each WS
|
|
465
|
+
// close as its own JS task, and N-API drains microtasks at the C++/JS
|
|
466
|
+
// boundary between tasks. A microtask-deferred flush fires BEFORE the
|
|
467
|
+
// next socket's close handler runs, so cross-socket coalescing is
|
|
468
|
+
// impossible at the microtask level. `setTimeout(0)` lands in libuv's
|
|
469
|
+
// timers phase, which fires only after the poll phase has dispatched
|
|
470
|
+
// every ready socket event in the current iteration.
|
|
471
|
+
/** @type {Map<string, Set<string>>} */
|
|
472
|
+
const pendingRemoves = new Map();
|
|
473
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
474
|
+
let removeFlushTimer = null;
|
|
475
|
+
/** @type {import('svelte-adapter-uws').Platform | null} */
|
|
476
|
+
let removeFlushPlatform = null;
|
|
477
|
+
|
|
478
|
+
function queueRemove(topic, key, platform) {
|
|
479
|
+
let set = pendingRemoves.get(topic);
|
|
480
|
+
if (!set) {
|
|
481
|
+
set = new Set();
|
|
482
|
+
pendingRemoves.set(topic, set);
|
|
483
|
+
}
|
|
484
|
+
set.add(key);
|
|
485
|
+
removeFlushPlatform = platform;
|
|
486
|
+
if (removeFlushTimer === null) {
|
|
487
|
+
removeFlushTimer = setTimeout(flushPendingRemoves, 0);
|
|
488
|
+
if (removeFlushTimer.unref) removeFlushTimer.unref();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function flushPendingRemoves() {
|
|
493
|
+
removeFlushTimer = null;
|
|
494
|
+
const platform = removeFlushPlatform;
|
|
495
|
+
removeFlushPlatform = null;
|
|
496
|
+
if (!platform) {
|
|
497
|
+
pendingRemoves.clear();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
for (const [topic, keys] of pendingRemoves) {
|
|
501
|
+
if (keys.size === 0) continue;
|
|
502
|
+
// publishBatched (adapter >= 0.5.0-next.5) bundles N events into
|
|
503
|
+
// one wire frame per subscriber. Each subscriber decodes the
|
|
504
|
+
// individual REMOVE events from the frame, so no client change
|
|
505
|
+
// is required. Fall back to per-event publishes when the
|
|
506
|
+
// adapter does not expose publishBatched.
|
|
507
|
+
if (typeof platform.publishBatched === 'function') {
|
|
508
|
+
const messages = [];
|
|
509
|
+
for (const key of keys) {
|
|
510
|
+
messages.push({ topic: '__cursor:' + topic, event: EVENTS.REMOVE, data: { key } });
|
|
511
|
+
}
|
|
512
|
+
try { platform.publishBatched(messages); } catch { /* platform unavailable mid-flight */ }
|
|
513
|
+
} else {
|
|
514
|
+
for (const key of keys) {
|
|
515
|
+
try { platform.publish('__cursor:' + topic, EVENTS.REMOVE, { key }); } catch { /* swallow */ }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
pendingRemoves.clear();
|
|
520
|
+
}
|
|
521
|
+
|
|
446
522
|
function relay(topic, event, payload) {
|
|
447
523
|
if (b) { try { b.guard(); } catch { return; } }
|
|
448
524
|
const msg = JSON.stringify({ instanceId, topic, event, payload });
|
|
@@ -694,7 +770,7 @@ export function createCursor(client, options = {}) {
|
|
|
694
770
|
if (topicPending.size === 0) redisPending.delete(topic);
|
|
695
771
|
}
|
|
696
772
|
|
|
697
|
-
|
|
773
|
+
queueRemove(topic, key, platform);
|
|
698
774
|
relay(topic, EVENTS.REMOVE, { key });
|
|
699
775
|
return true;
|
|
700
776
|
}
|
|
@@ -870,7 +946,7 @@ export function createCursor(client, options = {}) {
|
|
|
870
946
|
}
|
|
871
947
|
|
|
872
948
|
for (const t of removedTopics) {
|
|
873
|
-
|
|
949
|
+
queueRemove(t, state.key, platform);
|
|
874
950
|
const topicMap = topics.get(t);
|
|
875
951
|
if (topicMap) {
|
|
876
952
|
topicMap.delete(state.key);
|
|
@@ -961,6 +1037,9 @@ export function createCursor(client, options = {}) {
|
|
|
961
1037
|
}
|
|
962
1038
|
// Tracker-level scheduler timer + dirty-topic set.
|
|
963
1039
|
if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
|
|
1040
|
+
if (removeFlushTimer !== null) { clearTimeout(removeFlushTimer); removeFlushTimer = null; }
|
|
1041
|
+
removeFlushPlatform = null;
|
|
1042
|
+
pendingRemoves.clear();
|
|
964
1043
|
dirtyTopics.clear();
|
|
965
1044
|
topics.clear();
|
|
966
1045
|
topicFlush.clear();
|
|
@@ -982,6 +1061,9 @@ export function createCursor(client, options = {}) {
|
|
|
982
1061
|
}
|
|
983
1062
|
}
|
|
984
1063
|
if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
|
|
1064
|
+
if (removeFlushTimer !== null) { clearTimeout(removeFlushTimer); removeFlushTimer = null; }
|
|
1065
|
+
removeFlushPlatform = null;
|
|
1066
|
+
pendingRemoves.clear();
|
|
985
1067
|
dirtyTopics.clear();
|
|
986
1068
|
topicFlush.clear();
|
|
987
1069
|
if (subscriber) {
|
|
@@ -1048,6 +1130,16 @@ export function createCursor(client, options = {}) {
|
|
|
1048
1130
|
tracker.snapshot(ws, data.topic, platform);
|
|
1049
1131
|
return;
|
|
1050
1132
|
}
|
|
1133
|
+
// Silent no-op when the caller dispatches a parsed object whose
|
|
1134
|
+
// `type` is not ours. The dispatch-to-all pattern (e.g. forwarding
|
|
1135
|
+
// every unhandled frame to cursor.hooks.message AND
|
|
1136
|
+
// presence.hooks.message and letting each ignore frames not
|
|
1137
|
+
// addressed to it) is legitimate. Only warn on shapes that
|
|
1138
|
+
// indicate the wrong wiring (raw bytes, non-object) where the
|
|
1139
|
+
// developer almost certainly intended a parsed envelope.
|
|
1140
|
+
if (data && typeof data === 'object' && !Array.isArray(data) && typeof data.type === 'string') {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1051
1143
|
_warnCursorHooksMessageShape(data);
|
|
1052
1144
|
},
|
|
1053
1145
|
close(ws, { platform }) {
|