svelte-adapter-uws-extensions 0.1.5 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Redis and Postgres extensions for svelte-adapter-uws - distributed pub/sub, replay buffers, presence tracking, rate limiting, groups, and DB change notifications",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
package/redis/cursor.d.ts CHANGED
@@ -9,6 +9,15 @@ export interface RedisCursorOptions {
9
9
  */
10
10
  throttle?: number;
11
11
 
12
+ /**
13
+ * Minimum ms between aggregate broadcasts per topic, across all
14
+ * connections. Caps total Redis writes regardless of connection count.
15
+ * Set to ~16 (60 broadcasts/sec) to prevent Redis saturation under
16
+ * high concurrency. 0 disables the aggregate throttle.
17
+ * @default 0
18
+ */
19
+ topicThrottle?: number;
20
+
12
21
  /**
13
22
  * Extract user-identifying data from userData.
14
23
  * Broadcast alongside cursor data so other clients know who the cursor belongs to.
package/redis/cursor.js CHANGED
@@ -49,6 +49,9 @@ return removed
49
49
  * @typedef {Object} RedisCursorOptions
50
50
  * @property {number} [throttle=50] - Minimum ms between broadcasts per user per topic.
51
51
  * Trailing-edge timer fires to ensure the final position is always sent.
52
+ * @property {number} [topicThrottle=0] - Minimum ms between aggregate broadcasts per
53
+ * topic. Caps total Redis writes regardless of connection count. 0 = no limit.
54
+ * Set to ~16 (60/sec) to prevent Redis saturation under high concurrency.
52
55
  * @property {(userData: any) => any} [select] - Extract user-identifying data from userData.
53
56
  * Defaults to the full userData.
54
57
  * @property {number} [ttl=30] - TTL in seconds for hash entries. Should be longer than
@@ -80,6 +83,7 @@ return removed
80
83
  */
81
84
  export function createCursor(client, options = {}) {
82
85
  const throttleMs = options.throttle ?? 50;
86
+ const topicThrottleMs = options.topicThrottle ?? 0;
83
87
  const select = options.select || ((userData) => userData);
84
88
  const cursorTtl = options.ttl || 30;
85
89
 
@@ -189,7 +193,16 @@ export function createCursor(client, options = {}) {
189
193
  /**
190
194
  * Broadcast locally + relay to other instances via Redis.
191
195
  */
192
- function broadcast(topic, key, user, data, platform) {
196
+ /**
197
+ * Per-topic aggregate throttle state.
198
+ * When topicThrottleMs > 0, excess broadcasts are coalesced and
199
+ * flushed on a trailing-edge timer so total Redis load per topic
200
+ * is capped regardless of connection count.
201
+ * @type {Map<string, { lastFlush: number, timer: any, dirty: Map<string, { user: any, data: any, platform: any }> }>}
202
+ */
203
+ const topicFlush = new Map();
204
+
205
+ function doBroadcast(topic, key, user, data, platform) {
193
206
  // Local broadcast
194
207
  platform.publish('__cursor:' + topic, 'update', { key, user, data });
195
208
 
@@ -208,6 +221,97 @@ export function createCursor(client, options = {}) {
208
221
  redis.publish(channel, msg).catch(() => {});
209
222
  }
210
223
 
224
+ /**
225
+ * Flush all coalesced entries for a topic as a single "bulk" event.
226
+ * The client receives one event with all cursor positions instead of
227
+ * N individual events landing in the same microtask. This turns N
228
+ * store updates per frame into one, and reduces Redis PUBLISH calls
229
+ * from N to 1 per flush window.
230
+ *
231
+ * Each entry is still persisted individually to the Redis hash so
232
+ * the per-key TTL and staleness detection work unchanged.
233
+ */
234
+ function flushBulk(topic, dirty) {
235
+ const entries = [];
236
+ const now = Date.now();
237
+ let flushPlatform = null;
238
+
239
+ for (const [k, v] of dirty) {
240
+ entries.push({ key: k, user: v.user, data: v.data });
241
+ flushPlatform = v.platform;
242
+ // Persist each entry individually to Redis hash
243
+ redis.hset(hashKey(topic), k, JSON.stringify({ user: v.user, data: v.data, ts: now })).catch(() => {});
244
+ }
245
+
246
+ redis.expire(hashKey(topic), cursorTtl).catch(() => {});
247
+
248
+ if (flushPlatform) {
249
+ // Single local broadcast with all positions
250
+ flushPlatform.publish('__cursor:' + topic, 'bulk', entries);
251
+
252
+ // Single relay to other instances
253
+ const msg = JSON.stringify({
254
+ instanceId,
255
+ topic,
256
+ event: 'bulk',
257
+ payload: entries
258
+ });
259
+ redis.publish(channel, msg).catch(() => {});
260
+ }
261
+ }
262
+
263
+ function broadcast(topic, key, user, data, platform) {
264
+ if (topicThrottleMs <= 0) {
265
+ doBroadcast(topic, key, user, data, platform);
266
+ return;
267
+ }
268
+
269
+ // Per-topic aggregate throttle
270
+ let state = topicFlush.get(topic);
271
+ if (!state) {
272
+ state = { lastFlush: 0, timer: null, dirty: new Map() };
273
+ topicFlush.set(topic, state);
274
+ }
275
+
276
+ // Always store the latest data per key
277
+ state.dirty.set(key, { user, data, platform });
278
+
279
+ const now = Date.now();
280
+
281
+ // Leading edge: flush immediately if window has passed
282
+ if (now - state.lastFlush >= topicThrottleMs) {
283
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
284
+ state.lastFlush = now;
285
+ if (state.dirty.size === 1) {
286
+ // Single entry: use normal event so the client does not
287
+ // need to handle bulk for the common non-contended case.
288
+ const [k, v] = state.dirty.entries().next().value;
289
+ doBroadcast(topic, k, v.user, v.data, v.platform);
290
+ } else {
291
+ flushBulk(topic, state.dirty);
292
+ }
293
+ state.dirty.clear();
294
+ return;
295
+ }
296
+
297
+ // Trailing edge: schedule flush at end of window
298
+ if (!state.timer) {
299
+ state.timer = setTimeout(() => {
300
+ const s = topicFlush.get(topic);
301
+ if (!s) return;
302
+ s.timer = null;
303
+ s.lastFlush = Date.now();
304
+ if (s.dirty.size === 1) {
305
+ const [k, v] = s.dirty.entries().next().value;
306
+ doBroadcast(topic, k, v.user, v.data, v.platform);
307
+ } else {
308
+ flushBulk(topic, s.dirty);
309
+ }
310
+ s.dirty.clear();
311
+ }, topicThrottleMs - (now - state.lastFlush));
312
+ }
313
+ }
314
+
211
315
  function broadcastRemove(topic, key, platform) {
212
316
  platform.publish('__cursor:' + topic, 'remove', { key });
213
317
 
@@ -346,7 +450,11 @@ export function createCursor(client, options = {}) {
346
450
  if (entry.timer) clearTimeout(entry.timer);
347
451
  }
348
452
  }
453
+ for (const [, state] of topicFlush) {
454
+ if (state.timer) clearTimeout(state.timer);
455
+ }
349
456
  topics.clear();
457
+ topicFlush.clear();
350
458
  wsState.clear();
351
459
  activeTopics.clear();
352
460
  connCounter = 0;
@@ -371,6 +479,10 @@ export function createCursor(client, options = {}) {
371
479
  if (entry.timer) clearTimeout(entry.timer);
372
480
  }
373
481
  }
482
+ for (const [, state] of topicFlush) {
483
+ if (state.timer) clearTimeout(state.timer);
484
+ }
485
+ topicFlush.clear();
374
486
  if (subscriber) {
375
487
  subscriber.quit().catch(() => subscriber.disconnect());
376
488
  subscriber = null;
package/redis/presence.js CHANGED
@@ -60,11 +60,17 @@ for i = 1, #all, 2 do
60
60
  end
61
61
  end
62
62
 
63
- local result = {isFirst}
64
- for i = 1, #all do
65
- result[#result + 1] = all[i]
63
+ -- For small hashes return entries inline (saves a round trip).
64
+ -- For large hashes return only the flag; the caller fetches the
65
+ -- list via a coalesced HGETALL shared across concurrent joiners.
66
+ if #all <= 200 then
67
+ local result = {isFirst}
68
+ for i = 1, #all do
69
+ result[#result + 1] = all[i]
70
+ end
71
+ return result
66
72
  end
67
- return result
73
+ return {isFirst}
68
74
  `;
69
75
 
70
76
  /**
@@ -268,6 +274,22 @@ export function createPresence(client, options = {}) {
268
274
  /** @type {Set<string>} */
269
275
  const activeTopics = new Set();
270
276
  const heartbeatTimer = setInterval(() => {
277
+ // Detect dead connections whose close handler never fired.
278
+ // Under mass disconnect, the runtime may drop close events.
279
+ // Probe each tracked ws; if the probe throws the socket is
280
+ // dead and we synchronously purge it from local state so the
281
+ // refresh loop below never touches it.
282
+ if (activePlatform) {
283
+ const dead = [];
284
+ for (const [ws] of wsTopics) {
285
+ try { ws.getBufferedAmount(); } catch { dead.push(ws); }
286
+ }
287
+ for (const ws of dead) {
288
+ // Full leave (sync Phase 1 + async Phase 2 fire-and-forget)
289
+ tracker.leave(ws, activePlatform).catch(() => {});
290
+ }
291
+ }
292
+
271
293
  const now = Date.now();
272
294
  for (const topic of activeTopics) {
273
295
  const data = localData.get(topic);
@@ -385,15 +407,14 @@ export function createPresence(client, options = {}) {
385
407
  // cleaned local state -- undo any Redis write and bail out.
386
408
  if (!wsTopics.has(ws)) return;
387
409
 
388
- // all will hold the raw hash entries for the initial list.
389
- // When prevCount === 0 the Lua script returns them inline
390
- // (no separate HGETALL needed). Otherwise we coalesce so
391
- // N connections joining the same topic share one round trip.
392
410
  let all;
393
411
 
394
412
  if (prevCount === 0) {
395
413
  // New user on this instance -- single Lua call does
396
- // HSET + EXPIRE + dedup check + returns all entries.
414
+ // HSET + EXPIRE + dedup check. For small hashes the
415
+ // script returns all entries inline (1 round trip).
416
+ // For large hashes it returns only the dedup flag and
417
+ // we fetch the list via coalesceHgetall below.
397
418
  const now = Date.now();
398
419
  const field = compoundField(key);
399
420
  const value = JSON.stringify({ data, ts: now });
@@ -410,11 +431,14 @@ export function createPresence(client, options = {}) {
410
431
  return;
411
432
  }
412
433
 
413
- // Parse result: [isFirst, field1, val1, field2, val2, ...]
414
434
  const isFirstGlobally = result[0];
415
- all = {};
416
- for (let i = 1; i < result.length; i += 2) {
417
- all[result[i]] = result[i + 1];
435
+
436
+ if (result.length > 1) {
437
+ // Small hash: entries returned inline
438
+ all = {};
439
+ for (let i = 1; i < result.length; i += 2) {
440
+ all[result[i]] = result[i + 1];
441
+ }
418
442
  }
419
443
 
420
444
  if (isFirstGlobally === 1) {
@@ -422,7 +446,12 @@ export function createPresence(client, options = {}) {
422
446
  platform.publish('__presence:' + topic, 'join', payload);
423
447
  await publishEvent(topic, 'join', payload);
424
448
  }
425
- } else {
449
+ }
450
+
451
+ // Fetch initial list via coalesced HGETALL when the script
452
+ // did not return entries inline (large hash or dedup join).
453
+ // Concurrent joiners share a single Redis round trip.
454
+ if (!all) {
426
455
  all = await coalesceHgetall(topic);
427
456
  }
428
457
 
@@ -574,23 +603,39 @@ export function createPresence(client, options = {}) {
574
603
 
575
604
  // Phase 2: Async Redis cleanup. Local state is already clean so
576
605
  // the heartbeat will not refresh any of these entries.
577
- for (const { topic, key, data, needsUnsub } of pendingLeaves) {
606
+ // Use a pipeline to batch all LEAVE_SCRIPT EVALs into a single
607
+ // Redis round trip. Under mass disconnect (1000+ connections)
608
+ // this avoids overwhelming the Redis command queue.
609
+ for (const { needsUnsub, topic } of pendingLeaves) {
578
610
  if (needsUnsub) {
579
611
  await unsubscribeFromTopic(topic);
580
612
  }
613
+ }
581
614
 
615
+ const now = Date.now();
616
+ const pipe = redis.pipeline();
617
+ for (const { topic, key } of pendingLeaves) {
582
618
  const field = compoundField(key);
583
619
  const suffix = '|' + key;
584
- const now = Date.now();
585
- const userGone = await redis.eval(
586
- LEAVE_SCRIPT, 1, hashKey(topic), field, suffix, now, presenceTtlMs
587
- );
620
+ pipe.eval(LEAVE_SCRIPT, 1, hashKey(topic), field, suffix, now, presenceTtlMs);
621
+ }
588
622
 
589
- if (userGone === 1) {
590
- const payload = { key, data };
591
- platform.publish('__presence:' + topic, 'leave', payload);
592
- await publishEvent(topic, 'leave', payload);
593
- }
623
+ let results;
624
+ try {
625
+ results = await pipe.exec();
626
+ } catch {
627
+ // Pipeline failed -- CLEANUP_SCRIPT will handle stale entries via TTL
628
+ return;
629
+ }
630
+
631
+ // Broadcast leave events for users that are completely gone
632
+ for (let i = 0; i < pendingLeaves.length; i++) {
633
+ const [err, userGone] = results[i];
634
+ if (err || userGone !== 1) continue;
635
+ const { topic, key, data } = pendingLeaves[i];
636
+ const payload = { key, data };
637
+ platform.publish('__presence:' + topic, 'leave', payload);
638
+ await publishEvent(topic, 'leave', payload);
594
639
  }
595
640
 
596
641
  // Async unsubscribe for sync-only observer topics