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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/redis/cursor.js +94 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
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
- platform.publish('__cursor:' + topic, EVENTS.REMOVE, { key });
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
- platform.publish('__cursor:' + t, EVENTS.REMOVE, { key: state.key });
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 }) {