svelte-adapter-uws-extensions 0.5.3 → 0.5.4
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 +2 -2
- package/redis/cursor.d.ts +26 -0
- package/redis/cursor.js +266 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws-extensions",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
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.
|
|
157
|
+
"svelte-adapter-uws": "^0.5.4"
|
|
158
158
|
},
|
|
159
159
|
"dependencies": {
|
|
160
160
|
"ioredis": "^5.0.0"
|
package/redis/cursor.d.ts
CHANGED
|
@@ -127,6 +127,32 @@ export interface RedisCursorTracker {
|
|
|
127
127
|
/** Stop the Redis subscriber and clear local timers. */
|
|
128
128
|
destroy(): void;
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Snapshot of scheduler health. Always available, near-zero cost.
|
|
132
|
+
*
|
|
133
|
+
* - `flushes`: total tick-driven flushes since tracker creation.
|
|
134
|
+
* - `driftMeanMs`: mean (target_deadline - actual_fire_time) across
|
|
135
|
+
* all tick-driven flushes. 0 means perfect cadence; values >
|
|
136
|
+
* `topicThrottle` indicate sustained event-loop saturation or CPU
|
|
137
|
+
* contention (consider a dedicated-CPU instance, or raise
|
|
138
|
+
* `topicThrottle`).
|
|
139
|
+
* - `driftMaxMs`: largest single observed late fire. Useful for
|
|
140
|
+
* spotting one-off GC pauses vs. sustained drift.
|
|
141
|
+
* - `dirtyTopicsCurrent`: topics with pending coalesced entries right
|
|
142
|
+
* now. Should hover near zero in healthy operation.
|
|
143
|
+
* - `activeTopicsTotal`: topics with at least one local cursor.
|
|
144
|
+
*
|
|
145
|
+
* Leading-edge synchronous flushes are not counted in drift stats -
|
|
146
|
+
* they fire on the call thread, not via the scheduler.
|
|
147
|
+
*/
|
|
148
|
+
stats(): {
|
|
149
|
+
flushes: number;
|
|
150
|
+
driftMeanMs: number;
|
|
151
|
+
driftMaxMs: number;
|
|
152
|
+
dirtyTopicsCurrent: number;
|
|
153
|
+
activeTopicsTotal: number;
|
|
154
|
+
};
|
|
155
|
+
|
|
130
156
|
/**
|
|
131
157
|
* Ready-made WebSocket hooks for cursor tracking.
|
|
132
158
|
*
|
package/redis/cursor.js
CHANGED
|
@@ -91,6 +91,7 @@ const EVENTS = Object.freeze({
|
|
|
91
91
|
* @property {(topic: string) => Promise<CursorEntry[]>} list
|
|
92
92
|
* @property {() => Promise<void>} clear
|
|
93
93
|
* @property {() => void} destroy - Stop the Redis subscriber
|
|
94
|
+
* @property {() => { flushes: number, driftMeanMs: number, driftMaxMs: number, dirtyTopicsCurrent: number, activeTopicsTotal: number }} stats - Scheduler health snapshot
|
|
94
95
|
*/
|
|
95
96
|
|
|
96
97
|
/**
|
|
@@ -198,7 +199,29 @@ export function createCursor(client, options = {}) {
|
|
|
198
199
|
const parsed = JSON.parse(message);
|
|
199
200
|
if (parsed.instanceId === instanceId) return;
|
|
200
201
|
if (!validator.acceptEnvelope(parsed.topic, parsed.event)) return;
|
|
201
|
-
if (activePlatform)
|
|
202
|
+
if (!activePlatform) return;
|
|
203
|
+
|
|
204
|
+
// Receiver-side coalescing for high-frequency cursor-position
|
|
205
|
+
// events. UPDATE / BULK enqueue into the local topic's
|
|
206
|
+
// inboundDirty map so the NEXT local flush emits one combined
|
|
207
|
+
// frame covering local + peer cursors. Pre-change, peer-
|
|
208
|
+
// relayed frames published immediately on receive, producing
|
|
209
|
+
// tight doublets at subscribers (one frame per worker per
|
|
210
|
+
// cycle, ms apart). Now one frame per subscriber per cycle
|
|
211
|
+
// regardless of worker count.
|
|
212
|
+
//
|
|
213
|
+
// CATALOG / JOIN / REMOVE stay immediate: low-frequency
|
|
214
|
+
// roster events where coalescing would add latency without
|
|
215
|
+
// smoothness benefit.
|
|
216
|
+
if (parsed.event === EVENTS.UPDATE && parsed.payload && typeof parsed.payload.key === 'string') {
|
|
217
|
+
enqueueInbound(parsed.topic, parsed.payload.key, parsed.payload.data, activePlatform);
|
|
218
|
+
} else if (parsed.event === EVENTS.BULK && Array.isArray(parsed.payload)) {
|
|
219
|
+
for (const entry of parsed.payload) {
|
|
220
|
+
if (entry && typeof entry.key === 'string') {
|
|
221
|
+
enqueueInbound(parsed.topic, entry.key, entry.data, activePlatform);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
202
225
|
activePlatform.publish(
|
|
203
226
|
'__cursor:' + parsed.topic,
|
|
204
227
|
parsed.event,
|
|
@@ -358,11 +381,53 @@ export function createCursor(client, options = {}) {
|
|
|
358
381
|
}
|
|
359
382
|
|
|
360
383
|
/**
|
|
361
|
-
* Per-topic aggregate
|
|
362
|
-
*
|
|
384
|
+
* Per-topic aggregate flush state.
|
|
385
|
+
*
|
|
386
|
+
* - `dirty`: locally-originated cursors. Flushed locally AND relayed.
|
|
387
|
+
* - `inboundDirty`: cursors received from peer instances via Redis pub/sub.
|
|
388
|
+
* Flushed locally ONLY (re-relaying would loop). Kept separate from
|
|
389
|
+
* `dirty` so the relay payload is structurally a subset of the local
|
|
390
|
+
* flush, not a per-entry origin check.
|
|
391
|
+
* - `lastFlush`: target-anchored timestamp of the most recent flush.
|
|
392
|
+
* Advanced by `topicThrottleMs` per cycle (not to actual fire time) so
|
|
393
|
+
* a single late tick does not compound drift on subsequent cycles.
|
|
394
|
+
*
|
|
395
|
+
* @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
|
|
363
396
|
*/
|
|
364
397
|
const topicFlush = new Map();
|
|
365
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Single scheduler-driven set: topics with at least one dirty entry
|
|
401
|
+
* awaiting flush. Bounded by mover count, not topic count, so the
|
|
402
|
+
* per-tick walk does not scan idle topics. Updated synchronously on
|
|
403
|
+
* `broadcast()` / `enqueueInbound()` and on every tick.
|
|
404
|
+
*
|
|
405
|
+
* @type {Set<string>}
|
|
406
|
+
*/
|
|
407
|
+
const dirtyTopics = new Set();
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Single timer for the whole tracker. Always points at the next earliest
|
|
411
|
+
* topic deadline (or null when idle). Replaces the previous per-topic
|
|
412
|
+
* setTimeout pattern: N pending timers -> 1 pending timer regardless of
|
|
413
|
+
* topic count. Scheduling overhead is O(dirty topics), not O(active
|
|
414
|
+
* topics), and a single late fire affects exactly one cycle (target-
|
|
415
|
+
* anchored, no drift compounding).
|
|
416
|
+
*
|
|
417
|
+
* @type {ReturnType<typeof setTimeout> | null}
|
|
418
|
+
*/
|
|
419
|
+
let tickTimer = null;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Drift accounting for observability. Updated on every flush in `tick()`.
|
|
423
|
+
* Exposed via the `stats()` accessor; optional `metrics` integration
|
|
424
|
+
* (Prometheus histogram) is wired separately.
|
|
425
|
+
*/
|
|
426
|
+
let driftSum = 0;
|
|
427
|
+
let driftCount = 0;
|
|
428
|
+
let driftMax = 0;
|
|
429
|
+
let flushCount = 0;
|
|
430
|
+
|
|
366
431
|
function relay(topic, event, payload) {
|
|
367
432
|
if (b) { try { b.guard(); } catch { return; } }
|
|
368
433
|
const msg = JSON.stringify({ instanceId, topic, event, payload });
|
|
@@ -388,24 +453,129 @@ export function createCursor(client, options = {}) {
|
|
|
388
453
|
}
|
|
389
454
|
|
|
390
455
|
/**
|
|
391
|
-
* Flush
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
456
|
+
* Flush a topic's `dirty` + `inboundDirty` maps as a single wire frame to
|
|
457
|
+
* local subscribers, then relay the local-origin slice to peers.
|
|
458
|
+
*
|
|
459
|
+
* - Local subscribers see one combined frame per cycle covering this
|
|
460
|
+
* worker's own cursors PLUS any cursors received from peers since the
|
|
461
|
+
* last flush. Pre-change, peer-relayed cursors emitted as a separate
|
|
462
|
+
* frame immediately on receive, producing tight doublets at subscribers.
|
|
463
|
+
* - Peers receive only the local-origin slice (relay payload is built
|
|
464
|
+
* from `dirty`, not from `inboundDirty`). Re-relaying inbound cursors
|
|
465
|
+
* would loop: filtered at the receiver via `instanceId`, but still
|
|
466
|
+
* wastes Redis pub/sub bandwidth.
|
|
467
|
+
* - `queueSnapshot` runs for local-origin only. The originating worker
|
|
468
|
+
* owns the Redis HSET for its cursors; receivers must not re-write
|
|
469
|
+
* what the origin already wrote (would double the HSET storm).
|
|
470
|
+
*
|
|
471
|
+
* Single-entry vs. multi-entry choice mirrors the existing wire shape:
|
|
472
|
+
* one cursor -> `update {key, data}`, many -> `bulk [{key, data}, ...]`.
|
|
473
|
+
* Subscribers handle both as cursor-position frames.
|
|
395
474
|
*/
|
|
396
|
-
function
|
|
475
|
+
function flushBoth(topic, state) {
|
|
397
476
|
const entries = [];
|
|
398
477
|
let flushPlatform = null;
|
|
399
|
-
|
|
478
|
+
let localCount = 0;
|
|
479
|
+
|
|
480
|
+
// Local-origin slice first so we can take a prefix for the relay.
|
|
481
|
+
for (const [k, v] of state.dirty) {
|
|
400
482
|
entries.push({ key: k, data: v.data });
|
|
401
483
|
flushPlatform = v.platform;
|
|
402
484
|
queueSnapshot(topic, k, v.user, v.data);
|
|
485
|
+
localCount++;
|
|
403
486
|
}
|
|
487
|
+
for (const [k, v] of state.inboundDirty) {
|
|
488
|
+
entries.push({ key: k, data: v.data });
|
|
489
|
+
flushPlatform ||= v.platform;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
state.dirty.clear();
|
|
493
|
+
state.inboundDirty.clear();
|
|
494
|
+
|
|
404
495
|
if (!flushPlatform || entries.length === 0) return;
|
|
405
|
-
|
|
406
|
-
|
|
496
|
+
|
|
497
|
+
mBroadcasts?.inc({ topic: mt(topic) });
|
|
498
|
+
flushCount++;
|
|
499
|
+
|
|
500
|
+
// Single local publish covering all entries (local + inbound).
|
|
501
|
+
if (entries.length === 1) {
|
|
502
|
+
flushPlatform.publish('__cursor:' + topic, EVENTS.UPDATE, entries[0]);
|
|
503
|
+
} else {
|
|
504
|
+
flushPlatform.publish('__cursor:' + topic, EVENTS.BULK, entries);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Relay LOCAL-ORIGIN slice only; never re-relay what came from peers.
|
|
508
|
+
if (localCount > 0) {
|
|
509
|
+
if (localCount === 1) {
|
|
510
|
+
relay(topic, EVENTS.UPDATE, entries[0]);
|
|
511
|
+
} else {
|
|
512
|
+
relay(topic, EVENTS.BULK, entries.slice(0, localCount));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
407
515
|
}
|
|
408
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Scheduler tick. Walks `dirtyTopics`, flushes any topic whose deadline
|
|
519
|
+
* (`lastFlush + topicThrottleMs`) has passed, and re-arms `tickTimer`
|
|
520
|
+
* for the next earliest pending deadline. Topics whose deadline has not
|
|
521
|
+
* yet passed stay in `dirtyTopics` for the next tick.
|
|
522
|
+
*
|
|
523
|
+
* Target-anchored advance: on flush, `lastFlush` is set to the deadline
|
|
524
|
+
* (not the actual fire time) so a single late tick does not compound
|
|
525
|
+
* drift on subsequent cycles. If we fell behind by more than one cycle
|
|
526
|
+
* (event loop saturation > `topicThrottleMs`), `lastFlush` resets to
|
|
527
|
+
* `now` to avoid queueing phantom catch-up fires that would all hit the
|
|
528
|
+
* next event loop turn.
|
|
529
|
+
*/
|
|
530
|
+
function tick() {
|
|
531
|
+
tickTimer = null;
|
|
532
|
+
const now = Date.now();
|
|
533
|
+
let nextDeadline = Infinity;
|
|
534
|
+
|
|
535
|
+
for (const topic of dirtyTopics) {
|
|
536
|
+
const state = topicFlush.get(topic);
|
|
537
|
+
if (!state) { dirtyTopics.delete(topic); continue; }
|
|
538
|
+
if (state.dirty.size === 0 && state.inboundDirty.size === 0) {
|
|
539
|
+
dirtyTopics.delete(topic);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const deadline = state.lastFlush + topicThrottleMs;
|
|
543
|
+
if (deadline <= now) {
|
|
544
|
+
const drift = now - deadline;
|
|
545
|
+
driftSum += drift;
|
|
546
|
+
driftCount++;
|
|
547
|
+
if (drift > driftMax) driftMax = drift;
|
|
548
|
+
|
|
549
|
+
flushBoth(topic, state);
|
|
550
|
+
dirtyTopics.delete(topic);
|
|
551
|
+
|
|
552
|
+
// Target-anchored: advance lastFlush by the cadence amount.
|
|
553
|
+
// Multi-cycle backlog collapse to `now` so the next leading-
|
|
554
|
+
// edge check `(now - lastFlush) >= topicThrottleMs` works as
|
|
555
|
+
// expected without firing every queued cycle on this turn.
|
|
556
|
+
state.lastFlush = drift < topicThrottleMs ? deadline : now;
|
|
557
|
+
} else if (deadline < nextDeadline) {
|
|
558
|
+
nextDeadline = deadline;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (nextDeadline !== Infinity) {
|
|
563
|
+
tickTimer = setTimeout(tick, Math.max(0, nextDeadline - Date.now()));
|
|
564
|
+
}
|
|
565
|
+
// else: scheduler goes idle until next broadcast() / enqueueInbound().
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function armTick(delay) {
|
|
569
|
+
if (tickTimer !== null) return;
|
|
570
|
+
tickTimer = setTimeout(tick, delay);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Schedule a local cursor for the next coalesced flush. The leading-
|
|
575
|
+
* edge check fires synchronously when `topicThrottleMs` has elapsed
|
|
576
|
+
* since the last flush (preserves the contract that the first call on
|
|
577
|
+
* an idle topic publishes immediately, without a setTimeout(0) detour).
|
|
578
|
+
*/
|
|
409
579
|
function broadcast(topic, key, user, data, platform) {
|
|
410
580
|
if (topicThrottleMs <= 0) {
|
|
411
581
|
doBroadcast(topic, key, user, data, platform);
|
|
@@ -414,42 +584,64 @@ export function createCursor(client, options = {}) {
|
|
|
414
584
|
|
|
415
585
|
let state = topicFlush.get(topic);
|
|
416
586
|
if (!state) {
|
|
417
|
-
state = {
|
|
587
|
+
state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
|
|
418
588
|
topicFlush.set(topic, state);
|
|
419
589
|
}
|
|
420
|
-
|
|
421
590
|
state.dirty.set(key, { user, data, platform });
|
|
422
591
|
|
|
423
592
|
const now = Date.now();
|
|
424
|
-
|
|
425
593
|
if (now - state.lastFlush >= topicThrottleMs) {
|
|
426
|
-
|
|
594
|
+
// Leading-edge synchronous flush.
|
|
427
595
|
state.lastFlush = now;
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
doBroadcast(topic, k, v.user, v.data, v.platform);
|
|
431
|
-
} else {
|
|
432
|
-
flushBulk(topic, state.dirty);
|
|
433
|
-
}
|
|
434
|
-
state.dirty.clear();
|
|
596
|
+
flushBoth(topic, state);
|
|
597
|
+
dirtyTopics.delete(topic);
|
|
435
598
|
return;
|
|
436
599
|
}
|
|
437
600
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
601
|
+
// Within window: trailing-edge flush via the scheduler tick.
|
|
602
|
+
dirtyTopics.add(topic);
|
|
603
|
+
armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Schedule a peer-relayed cursor for the next coalesced flush. Symmetric
|
|
608
|
+
* to `broadcast()`: same leading/trailing edge semantics, but inbound
|
|
609
|
+
* entries route through `state.inboundDirty` so they are visible to
|
|
610
|
+
* local subscribers on the next flush WITHOUT being re-relayed to peers
|
|
611
|
+
* (which would loop) and WITHOUT being written to Redis (origin owns
|
|
612
|
+
* the HSET).
|
|
613
|
+
*
|
|
614
|
+
* The peer's cross-replica end-to-end latency gains up to one
|
|
615
|
+
* `topicThrottleMs` of coalescing delay on the receiver side. Cursors
|
|
616
|
+
* are already throttled in the 8-16ms range; adding 8-16ms is well
|
|
617
|
+
* below the ~50-100ms human perception threshold for cursor lag. The
|
|
618
|
+
* smoothness win (one frame per subscriber per cycle instead of two)
|
|
619
|
+
* is the structural benefit.
|
|
620
|
+
*/
|
|
621
|
+
function enqueueInbound(topic, key, data, platform) {
|
|
622
|
+
if (topicThrottleMs <= 0) {
|
|
623
|
+
// Legacy immediate mode (matches old receiver behavior).
|
|
624
|
+
platform.publish('__cursor:' + topic, EVENTS.UPDATE, { key, data }, { relay: false });
|
|
625
|
+
return;
|
|
452
626
|
}
|
|
627
|
+
|
|
628
|
+
let state = topicFlush.get(topic);
|
|
629
|
+
if (!state) {
|
|
630
|
+
state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
|
|
631
|
+
topicFlush.set(topic, state);
|
|
632
|
+
}
|
|
633
|
+
state.inboundDirty.set(key, { data, platform });
|
|
634
|
+
|
|
635
|
+
const now = Date.now();
|
|
636
|
+
if (now - state.lastFlush >= topicThrottleMs) {
|
|
637
|
+
state.lastFlush = now;
|
|
638
|
+
flushBoth(topic, state);
|
|
639
|
+
dirtyTopics.delete(topic);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
dirtyTopics.add(topic);
|
|
644
|
+
armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
|
|
453
645
|
}
|
|
454
646
|
|
|
455
647
|
async function broadcastRemove(topic, key, platform) {
|
|
@@ -580,6 +772,7 @@ export function createCursor(client, options = {}) {
|
|
|
580
772
|
topics.delete(topic);
|
|
581
773
|
activeTopics.delete(topic);
|
|
582
774
|
topicFlush.delete(topic);
|
|
775
|
+
dirtyTopics.delete(topic);
|
|
583
776
|
redisPending.delete(topic);
|
|
584
777
|
stopCleanupTimer();
|
|
585
778
|
}
|
|
@@ -637,6 +830,7 @@ export function createCursor(client, options = {}) {
|
|
|
637
830
|
topics.delete(t);
|
|
638
831
|
activeTopics.delete(t);
|
|
639
832
|
topicFlush.delete(t);
|
|
833
|
+
dirtyTopics.delete(t);
|
|
640
834
|
redisPending.delete(t);
|
|
641
835
|
}
|
|
642
836
|
}
|
|
@@ -717,9 +911,9 @@ export function createCursor(client, options = {}) {
|
|
|
717
911
|
if (entry.timer) clearTimeout(entry.timer);
|
|
718
912
|
}
|
|
719
913
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
914
|
+
// Tracker-level scheduler timer + dirty-topic set.
|
|
915
|
+
if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
|
|
916
|
+
dirtyTopics.clear();
|
|
723
917
|
topics.clear();
|
|
724
918
|
topicFlush.clear();
|
|
725
919
|
wsState.clear();
|
|
@@ -739,9 +933,8 @@ export function createCursor(client, options = {}) {
|
|
|
739
933
|
if (entry.timer) clearTimeout(entry.timer);
|
|
740
934
|
}
|
|
741
935
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
}
|
|
936
|
+
if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
|
|
937
|
+
dirtyTopics.clear();
|
|
745
938
|
topicFlush.clear();
|
|
746
939
|
if (subscriber) {
|
|
747
940
|
subscriber.quit().catch(() => subscriber.disconnect());
|
|
@@ -750,6 +943,37 @@ export function createCursor(client, options = {}) {
|
|
|
750
943
|
activePlatform = null;
|
|
751
944
|
},
|
|
752
945
|
|
|
946
|
+
/**
|
|
947
|
+
* Snapshot of scheduler health. Always available, near-zero cost.
|
|
948
|
+
*
|
|
949
|
+
* - `flushes`: total tick-driven flushes since tracker creation.
|
|
950
|
+
* - `driftMeanMs`: mean (target_deadline - actual_fire_time) across
|
|
951
|
+
* all tick-driven flushes. 0 means perfect cadence; values >
|
|
952
|
+
* `topicThrottle` indicate sustained event-loop saturation or
|
|
953
|
+
* CPU contention.
|
|
954
|
+
* - `driftMaxMs`: largest single observed late fire. Useful for
|
|
955
|
+
* spotting one-off GC pauses vs. sustained drift.
|
|
956
|
+
* - `dirtyTopicsCurrent`: topics with pending coalesced entries
|
|
957
|
+
* right now. Should hover near zero in healthy operation; growth
|
|
958
|
+
* means tick is falling behind.
|
|
959
|
+
* - `activeTopicsTotal`: topics with at least one local cursor.
|
|
960
|
+
*
|
|
961
|
+
* Leading-edge synchronous flushes (first call on an idle topic)
|
|
962
|
+
* are not counted in drift stats - they fire on the call thread,
|
|
963
|
+
* not via the scheduler.
|
|
964
|
+
*
|
|
965
|
+
* @returns {{ flushes: number, driftMeanMs: number, driftMaxMs: number, dirtyTopicsCurrent: number, activeTopicsTotal: number }}
|
|
966
|
+
*/
|
|
967
|
+
stats() {
|
|
968
|
+
return {
|
|
969
|
+
flushes: flushCount,
|
|
970
|
+
driftMeanMs: driftCount > 0 ? driftSum / driftCount : 0,
|
|
971
|
+
driftMaxMs: driftMax,
|
|
972
|
+
dirtyTopicsCurrent: dirtyTopics.size,
|
|
973
|
+
activeTopicsTotal: topics.size
|
|
974
|
+
};
|
|
975
|
+
},
|
|
976
|
+
|
|
753
977
|
hooks: {
|
|
754
978
|
subscribe(ws, topic, { platform }) {
|
|
755
979
|
if (topic.startsWith('__cursor:')) {
|