svelte-adapter-uws-extensions 0.5.6 → 0.5.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/redis/cursor.js +50 -71
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -154,7 +154,7 @@
154
154
  "node": ">=22.0.0"
155
155
  },
156
156
  "peerDependencies": {
157
- "svelte-adapter-uws": "^0.5.5"
157
+ "svelte-adapter-uws": "^0.5.6"
158
158
  },
159
159
  "dependencies": {
160
160
  "ioredis": "^5.0.0"
package/redis/cursor.js CHANGED
@@ -403,16 +403,11 @@ export function createCursor(client, options = {}) {
403
403
  * - `lastFlush`: target-anchored timestamp of the most recent flush.
404
404
  * Advanced by `topicThrottleMs` per cycle (not to actual fire time) so
405
405
  * a single late tick does not compound drift on subsequent cycles.
406
- * - `pendingMicroflush`: a leading-edge flush is queued for the current
407
- * cadence slot; co-arriving broadcasts (from either local OR peer-
408
- * relay sources) in the same JS pass append to `dirty` /
409
- * `inboundDirty` and ship as one combined frame when the microtask
410
- * runs. Without this, a single co-arriving cursor fired alone as a
411
- * single-cursor UPDATE while every other cursor in the same pass
412
- * queued for the trailing tick - 86 percent fragmentation observed at
413
- * 1000-mover load on Hetzner CCX13 with `topicThrottle: 8`.
406
+ * Initialized to `Date.now() - topicThrottleMs` so the first broadcast
407
+ * on a new topic is "cycle ready" without polluting drift stats with
408
+ * the full `Date.now()` lateness an init of 0 would imply.
414
409
  *
415
- * @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number, pendingMicroflush: boolean }>}
410
+ * @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
416
411
  */
417
412
  const topicFlush = new Map();
418
413
 
@@ -570,9 +565,10 @@ export function createCursor(client, options = {}) {
570
565
  dirtyTopics.delete(topic);
571
566
 
572
567
  // Target-anchored: advance lastFlush by the cadence amount.
573
- // Multi-cycle backlog collapse to `now` so the next leading-
574
- // edge check `(now - lastFlush) >= topicThrottleMs` works as
575
- // expected without firing every queued cycle on this turn.
568
+ // Multi-cycle backlog collapse to `now` so the next
569
+ // broadcast's `Date.now() - lastFlush >= topicThrottleMs`
570
+ // delay computation does not fire every queued cycle on
571
+ // this turn.
576
572
  state.lastFlush = drift < topicThrottleMs ? deadline : now;
577
573
  } else if (deadline < nextDeadline) {
578
574
  nextDeadline = deadline;
@@ -591,21 +587,31 @@ export function createCursor(client, options = {}) {
591
587
  }
592
588
 
593
589
  /**
594
- * Schedule a local cursor for the next coalesced flush. The leading-edge
595
- * check claims the cadence slot synchronously when `topicThrottleMs` has
596
- * elapsed since the last flush, then defers the actual `flushBoth` by one
597
- * microtask. Co-arriving broadcasts in the same JS pass (from this
598
- * function OR `enqueueInbound` - they share `state.pendingMicroflush`)
599
- * see `now - state.lastFlush < topicThrottleMs` and take the trailing
600
- * path, accumulating into `state.dirty` / `state.inboundDirty`. The
601
- * microtask then flushes everything as one combined frame.
590
+ * Schedule a local cursor for the next coalesced flush. Always-tick: every
591
+ * call appends to `state.dirty`, adds the topic to `dirtyTopics`, and arms
592
+ * the tracker-wide tick timer. NO leading-edge synchronous fire and NO
593
+ * microtask defer.
602
594
  *
603
- * Without this defer, the first cursor in a post-pause burst fires alone
604
- * as a single-cursor UPDATE while every co-arriving cursor in the same
605
- * pass queues for the trailing tick. Demo measured 86 percent
606
- * fragmentation (1794 single UPDATEs vs 38 BULKs in 30s) at 1000-mover
607
- * load on Hetzner CCX13 with `topicThrottle: 8`. After the defer:
608
- * single UPDATE rate collapses, BULK rate matches cycle rate.
595
+ * Why: uWS dispatches each WS message as its own JS task, and N-API
596
+ * drains microtasks at the C++/JS boundary between dispatches. A
597
+ * `queueMicrotask`-deferred flush fires BEFORE the next socket's message
598
+ * handler runs, so cross-socket coalescing is impossible at the microtask
599
+ * level. `setTimeout(0)` is in libuv's timers phase and fires only after
600
+ * the poll phase processes every ready message on every socket - so all
601
+ * broadcasts dispatched in the same loop iteration end up in one flush
602
+ * regardless of how many task boundaries separate them.
603
+ *
604
+ * 0.5.5/0.5.6 shipped a `queueMicrotask` + `pendingMicroflush` variant
605
+ * built on the wrong dispatch-model assumption (that co-arriving
606
+ * broadcasts share a JS task). Demo measured ~99% single-cursor UPDATE /
607
+ * ~1% BULK at 1000-mover load on the deployed 0.5.6 - essentially the
608
+ * pre-fix shape. The bench validated the assumed input shape (all
609
+ * broadcasts in one synchronous task) instead of the input shape uWS
610
+ * actually produces (per-message tasks separated by microtask drains).
611
+ *
612
+ * First-cursor latency cost of always-tick: up to `topicThrottleMs` (16ms
613
+ * default, one frame budget) before fanout. Below the perceptual floor
614
+ * for cursors. Same trade-off the adapter's bundled cursor plugin makes.
609
615
  */
610
616
  function broadcast(topic, key, user, data, platform) {
611
617
  if (topicThrottleMs <= 0) {
@@ -615,34 +621,19 @@ export function createCursor(client, options = {}) {
615
621
 
616
622
  let state = topicFlush.get(topic);
617
623
  if (!state) {
618
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0, pendingMicroflush: false };
624
+ // Anchor `lastFlush` one cycle in the past so the first broadcast
625
+ // is treated as "cycle ready" with zero drift on the very first
626
+ // tick. Without this, `Date.now() - 0` would be a huge "lateness"
627
+ // that pollutes the drift stats forever.
628
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
619
629
  topicFlush.set(topic, state);
620
630
  }
621
631
  state.dirty.set(key, { user, data, platform });
622
-
623
- const now = Date.now();
624
- if (now - state.lastFlush >= topicThrottleMs) {
625
- // Claim the cadence slot synchronously so subsequent broadcasts
626
- // in the same JS pass take the trailing path and accumulate.
627
- state.lastFlush = now;
628
- dirtyTopics.delete(topic);
629
- if (!state.pendingMicroflush) {
630
- state.pendingMicroflush = true;
631
- queueMicrotask(() => {
632
- state.pendingMicroflush = false;
633
- // flushBoth clears `dirty` and `inboundDirty` internally
634
- // and early-returns on empty; this guard saves the
635
- // for-of entry cost on the empty case.
636
- if (state.dirty.size === 0 && state.inboundDirty.size === 0) return;
637
- flushBoth(topic, state);
638
- });
639
- }
640
- return;
641
- }
642
-
643
- // Within window: trailing-edge flush via the scheduler tick.
644
632
  dirtyTopics.add(topic);
645
- armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
633
+
634
+ const elapsed = Date.now() - state.lastFlush;
635
+ const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
636
+ armTick(delay);
646
637
  }
647
638
 
648
639
  /**
@@ -669,31 +660,19 @@ export function createCursor(client, options = {}) {
669
660
 
670
661
  let state = topicFlush.get(topic);
671
662
  if (!state) {
672
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0, pendingMicroflush: false };
663
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
673
664
  topicFlush.set(topic, state);
674
665
  }
675
666
  state.inboundDirty.set(key, { data, platform });
676
-
677
- const now = Date.now();
678
- if (now - state.lastFlush >= topicThrottleMs) {
679
- // Symmetric with `broadcast()`: same shared `pendingMicroflush`
680
- // per topic state, so if a local broadcast already claimed the
681
- // slot the peer-relay entry just appends and ships with it.
682
- state.lastFlush = now;
683
- dirtyTopics.delete(topic);
684
- if (!state.pendingMicroflush) {
685
- state.pendingMicroflush = true;
686
- queueMicrotask(() => {
687
- state.pendingMicroflush = false;
688
- if (state.dirty.size === 0 && state.inboundDirty.size === 0) return;
689
- flushBoth(topic, state);
690
- });
691
- }
692
- return;
693
- }
694
-
695
667
  dirtyTopics.add(topic);
696
- armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
668
+
669
+ // Symmetric with `broadcast()` always-tick: both source paths share
670
+ // the same `state` and the same tracker-wide tick timer, so a local
671
+ // broadcast and a peer-relayed inbound landing in the same loop
672
+ // iteration ship together as one combined frame at the next tick.
673
+ const elapsed = Date.now() - state.lastFlush;
674
+ const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
675
+ armTick(delay);
697
676
  }
698
677
 
699
678
  async function broadcastRemove(topic, key, platform) {