svelte-adapter-uws-extensions 0.5.7 → 0.5.9
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/MIGRATION.md +8 -8
- package/README.md +9 -9
- package/package.json +2 -2
- package/redis/presence.d.ts +3 -3
- package/redis/presence.js +51 -26
- package/redis/pubsub.js +31 -11
- package/redis/sharded-pubsub.js +29 -13
package/MIGRATION.md
CHANGED
|
@@ -36,7 +36,7 @@ Both hashes have per-field TTLs via `HPEXPIRE`. The pre-fix whole-key EXPIRE is
|
|
|
36
36
|
|
|
37
37
|
3. **`metrics().staleCleanedTotal` is now always 0.** Per-field staleness is enforced atomically by Redis HPEXPIRE rather than by an application-side cleanup script, so there is nothing to count. The field stays in the return shape for backward-compatibility. The corresponding Prometheus counter `presence_stale_cleaned_total` is no longer registered.
|
|
38
38
|
|
|
39
|
-
4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `
|
|
39
|
+
4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `state` when one fired. Same shape in 0.5 (the per-topic hash whole-key expiry only fires when every field has expired, i.e. no live instances). Per-field expiry (a single crashed instance) does NOT trigger this notification; users disappear from `list()` / `count()` results lazily. If you need explicit per-field-expiry events, subscribe to `__keyevent@*__:hexpired` separately (separate Redis CONFIG flag).
|
|
40
40
|
|
|
41
41
|
5. **Falling back to single-instance.** If you can't upgrade Redis, switch to the in-memory `createPresence` plugin from `svelte-adapter-uws/plugins/presence`. Public API is the same; you lose cross-instance presence which is the whole point of the Redis variant, but for single-instance deployments it's a clean drop-in.
|
|
42
42
|
|
|
@@ -219,19 +219,19 @@ export function leaveBoard(ws, { topic, platform }) {
|
|
|
219
219
|
|
|
220
220
|
No `close`-hook change required: uWS releases all per-`ws` subscriptions on disconnect, so `cursors.remove(ws, platform)` in your `close` hook still handles cleanup. The legacy `cursors.hooks.subscribe` slot is now a no-op (the adapter's wire gate prevents it from ever firing); it stays exported for source-compat but new code should not rely on it.
|
|
221
221
|
|
|
222
|
-
### Presence wire shape on `__presence:{topic}` migrated to `
|
|
222
|
+
### Presence wire shape on `__presence:{topic}` migrated to `state` / `diff`
|
|
223
223
|
|
|
224
224
|
**Only impacts apps with hand-rolled WebSocket clients consuming the wire directly.**
|
|
225
225
|
|
|
226
226
|
**What changed.** The `redis/presence` channel previously emitted `list` / `join` / `leave` / `updated` / `heartbeat` events. It now emits:
|
|
227
227
|
|
|
228
|
-
- `
|
|
229
|
-
- `
|
|
228
|
+
- `state` (sent once on subscribe to a single connection): payload is `{[userKey]: data}` - a flat snapshot.
|
|
229
|
+
- `diff` (broadcast to topic subscribers, microtask-batched): payload is `{joins: {[key]: data}, leaves: {[key]: data}}`. Same-tick joins+leaves on the same key collapse to the latest op; updates appear as a `joins` entry with the new data.
|
|
230
230
|
- `heartbeat` is unchanged.
|
|
231
231
|
|
|
232
|
-
The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `
|
|
232
|
+
The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `state` event (was an empty `list` event) on hash expiry.
|
|
233
233
|
|
|
234
|
-
**How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `
|
|
234
|
+
**How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `state` / `diff`:
|
|
235
235
|
|
|
236
236
|
```js
|
|
237
237
|
// before
|
|
@@ -241,11 +241,11 @@ case 'leave': remove(payload.key); break;
|
|
|
241
241
|
case 'updated': set(payload.key, payload.data); break;
|
|
242
242
|
|
|
243
243
|
// after
|
|
244
|
-
case '
|
|
244
|
+
case 'state':
|
|
245
245
|
state.clear();
|
|
246
246
|
for (const k of Object.keys(payload)) state.set(k, payload[k]);
|
|
247
247
|
break;
|
|
248
|
-
case '
|
|
248
|
+
case 'diff':
|
|
249
249
|
for (const k of Object.keys(payload.joins)) state.set(k, payload.joins[k]);
|
|
250
250
|
for (const k of Object.keys(payload.leaves)) state.delete(k);
|
|
251
251
|
break;
|
package/README.md
CHANGED
|
@@ -608,13 +608,13 @@ Clients see three event types on `__presence:{topic}`. Mirrors the adapter's bun
|
|
|
608
608
|
|
|
609
609
|
| Event | When | Payload | Direction |
|
|
610
610
|
|---|---|---|---|
|
|
611
|
-
| `
|
|
612
|
-
| `
|
|
611
|
+
| `state` | Once on subscribe | `{[userKey]: data}` - flat snapshot of current presence | Server -> single connection |
|
|
612
|
+
| `diff` | Microtask-batched after joins / leaves / updates | `{joins: {[key]: data}, leaves: {[key]: data}}` | Server -> topic subscribers |
|
|
613
613
|
| `heartbeat` | Per heartbeat interval | `string[]` - array of currently-known user keys | Server -> topic subscribers |
|
|
614
614
|
|
|
615
|
-
`
|
|
615
|
+
`diff` collapses by key per-tick: if the same user joins and leaves in the same microtask, only the latest op survives on the wire. An update (same user re-joins with different data) appears as a `joins` entry carrying the new data, since clients overwrite their `Map.set` on the same key.
|
|
616
616
|
|
|
617
|
-
Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `
|
|
617
|
+
Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `state` / `diff` shape regardless of which instance the change originated on.
|
|
618
618
|
|
|
619
619
|
Joins are staged with full rollback on failure: local state is set up first, then the Redis hashes are written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone - local maps, the Redis state, and any buffered diff entry are reversed. Compensating join+leave ops on the same key in the same tick collapse to nothing on the wire.
|
|
620
620
|
|
|
@@ -662,7 +662,7 @@ export async function close(ws, { platform }) {
|
|
|
662
662
|
| `select` | strips `__`-prefixed keys | Extract public fields from userData |
|
|
663
663
|
| `heartbeat` | `30000` | TTL refresh interval in ms |
|
|
664
664
|
| `ttl` | `90` | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
|
|
665
|
-
| `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `
|
|
665
|
+
| `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `state` event. See [Keyspace cleanup mode](#keyspace-cleanup-mode). |
|
|
666
666
|
|
|
667
667
|
#### API
|
|
668
668
|
|
|
@@ -674,7 +674,7 @@ export async function close(ws, { platform }) {
|
|
|
674
674
|
| `list(topic)` | Get current users |
|
|
675
675
|
| `count(topic)` | Count unique users |
|
|
676
676
|
| `metrics()` | Synchronous snapshot: `{ totalOnline, heartbeatLatencyMs, staleCleanedTotal }`. See [Metrics snapshot](#metrics-snapshot). |
|
|
677
|
-
| `flushDiffs()` | Drain the pending `
|
|
677
|
+
| `flushDiffs()` | Drain the pending `diff` buffer synchronously. Use in graceful-shutdown paths or tests that need the diff to land before the await chain continues. |
|
|
678
678
|
| `clear()` | Reset all presence state |
|
|
679
679
|
| `destroy()` | Stop heartbeat and subscriber |
|
|
680
680
|
| `hooks` | `{ subscribe, close }` - ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
|
|
@@ -695,14 +695,14 @@ Two additional counters track the diff-protocol behavior:
|
|
|
695
695
|
|
|
696
696
|
| Metric | Description |
|
|
697
697
|
|---|---|
|
|
698
|
-
| `presence_diff_frames_total{topic="..."}` | `
|
|
698
|
+
| `presence_diff_frames_total{topic="..."}` | `diff` frames published to topic subscribers. Compared against `presence_joins_total` + `presence_leaves_total` it tells you how much per-tick coalescing the buffer is doing - the bigger the gap, the more bandwidth saved versus per-event broadcast. |
|
|
699
699
|
| `presence_diff_coalesced_total{topic="..."}` | Buffered diff entries overwritten by a later op in the same tick. A non-zero rate confirms the same-key collapse is working (e.g. a user reconnecting fast enough to leave-then-join in one tick). Zero is also a valid state under steady traffic. |
|
|
700
700
|
|
|
701
701
|
#### Keyspace cleanup mode
|
|
702
702
|
|
|
703
|
-
By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `
|
|
703
|
+
By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `diff` with the user in `leaves`. If the tracking instance crashes, the broadcast never fires and the observer's UI shows stale data until the page is reloaded.
|
|
704
704
|
|
|
705
|
-
`keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore - typically because the only tracker crashed), this instance emits an empty `
|
|
705
|
+
`keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore - typically because the only tracker crashed), this instance emits an empty `state` event on `__presence:<topic>` so local subscribers can replace their entire local map with "no one here."
|
|
706
706
|
|
|
707
707
|
```js
|
|
708
708
|
const presence = createPresence(redis, {
|
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.9",
|
|
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.8"
|
|
158
158
|
},
|
|
159
159
|
"dependencies": {
|
|
160
160
|
"ioredis": "^5.0.0"
|
package/redis/presence.d.ts
CHANGED
|
@@ -38,8 +38,8 @@ export interface RedisPresenceOptions {
|
|
|
38
38
|
* both single-instance and cluster deployments.
|
|
39
39
|
*/
|
|
40
40
|
export type PresenceWireEvent =
|
|
41
|
-
| { event: '
|
|
42
|
-
| { event: '
|
|
41
|
+
| { event: 'state'; data: Record<string, Record<string, any>> }
|
|
42
|
+
| { event: 'diff'; data: { joins: Record<string, Record<string, any>>; leaves: Record<string, Record<string, any>> } }
|
|
43
43
|
| { event: 'heartbeat'; data: string[] };
|
|
44
44
|
|
|
45
45
|
export interface PresenceMetricsSnapshot {
|
|
@@ -101,7 +101,7 @@ export interface RedisPresenceTracker {
|
|
|
101
101
|
* Drain the pending diff buffer synchronously. The diff buffer
|
|
102
102
|
* normally flushes on the next microtask after a join / leave /
|
|
103
103
|
* update; call this when a test or graceful-shutdown path needs
|
|
104
|
-
* the `
|
|
104
|
+
* the `diff` to land before the await chain continues.
|
|
105
105
|
*/
|
|
106
106
|
flushDiffs(): void;
|
|
107
107
|
|
package/redis/presence.js
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* for cross-instance join/leave notifications.
|
|
7
7
|
*
|
|
8
8
|
* Wire shape clients see on `__presence:{topic}`:
|
|
9
|
-
* - `
|
|
9
|
+
* - `state` (sent once on subscribe to a single connection)
|
|
10
10
|
* payload: `{[userKey]: data}` flat snapshot of current presence
|
|
11
|
-
* - `
|
|
11
|
+
* - `diff` (broadcast to topic subscribers, tick-batched)
|
|
12
12
|
* payload: `{joins: {[key]: data}, leaves: {[key]: data}}`
|
|
13
|
-
*
|
|
13
|
+
* Joins+leaves on the same key in one event-loop iteration
|
|
14
|
+
* collapse: latest op wins.
|
|
14
15
|
* - `heartbeat` (broadcast to topic subscribers, per heartbeat interval)
|
|
15
16
|
* payload: array of currently-known user keys
|
|
16
17
|
*
|
|
@@ -148,7 +149,7 @@ return 0
|
|
|
148
149
|
|
|
149
150
|
/**
|
|
150
151
|
* Internal cross-instance Redis pub/sub envelope event names. NOT the
|
|
151
|
-
* client wire shape - clients see `
|
|
152
|
+
* client wire shape - clients see `state` / `diff` /
|
|
152
153
|
* `heartbeat`. These names live on the `presence:events:{topic}` channel
|
|
153
154
|
* between instances and are routed into the local diff buffer on receive.
|
|
154
155
|
*/
|
|
@@ -256,7 +257,7 @@ export function createPresence(client, options = {}) {
|
|
|
256
257
|
const mTotalOnline = m?.gauge('presence_total_online', 'Unique users present per topic on this instance', ['topic']);
|
|
257
258
|
const mHeartbeatLatency = m?.gauge('presence_heartbeat_latency_ms', 'Duration of the most recent heartbeat tick in milliseconds');
|
|
258
259
|
const mKeyspaceCleanups = m?.counter('presence_keyspace_cleanups_total', 'Topics whose hash expiry triggered a local empty-list emit');
|
|
259
|
-
const mDiffFrames = m?.counter('presence_diff_frames_total', '
|
|
260
|
+
const mDiffFrames = m?.counter('presence_diff_frames_total', 'diff frames published to topic subscribers', ['topic']);
|
|
260
261
|
const mDiffCoalesced = m?.counter('presence_diff_coalesced_total', 'Buffered diff entries overwritten by a later op in the same tick', ['topic']);
|
|
261
262
|
|
|
262
263
|
let lastHeartbeatLatency = 0;
|
|
@@ -317,14 +318,29 @@ export function createPresence(client, options = {}) {
|
|
|
317
318
|
|
|
318
319
|
/**
|
|
319
320
|
* Per-topic pending diff buffer: latest op per key wins. Joins and
|
|
320
|
-
* leaves on the same key in
|
|
321
|
-
* only sees the net change. Flushed once per
|
|
322
|
-
* `
|
|
323
|
-
*
|
|
321
|
+
* leaves on the same key in one event-loop iteration collapse so the
|
|
322
|
+
* wire only sees the net change. Flushed once per iteration via
|
|
323
|
+
* `setTimeout(flushPendingDiffs, 0)` armed when the first dirty entry
|
|
324
|
+
* lands. Mirrors the buffer model the adapter's bundled presence
|
|
325
|
+
* plugin uses, so a single client decoder handles both.
|
|
326
|
+
*
|
|
327
|
+
* Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS
|
|
328
|
+
* message as its own JS task, and N-API drains microtasks at the C++/JS
|
|
329
|
+
* boundary between tasks. A microtask-deferred flush fires BEFORE the
|
|
330
|
+
* next socket's handler runs, so cross-socket coalescing is impossible
|
|
331
|
+
* at the microtask level - a mass-join into a populated topic produces
|
|
332
|
+
* O(N) one-entry publishes instead of one batched diff. `setTimeout(0)`
|
|
333
|
+
* lands in libuv's timers phase, which fires only after the poll phase
|
|
334
|
+
* has dispatched every ready socket message in the current iteration -
|
|
335
|
+
* so all joins arriving together end up in one flush regardless of how
|
|
336
|
+
* many task boundaries separate them. Same structural choice the
|
|
337
|
+
* 0.5.7 cursor always-tick rewrite locked in.
|
|
338
|
+
*
|
|
324
339
|
* @type {Map<string, Map<string, { op: 'join' | 'leave', data: Record<string, any> }>>}
|
|
325
340
|
*/
|
|
326
341
|
const pendingDiffs = new Map();
|
|
327
|
-
|
|
342
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
343
|
+
let diffFlushTimer = null;
|
|
328
344
|
/** @type {import('svelte-adapter-uws').Platform | null} */
|
|
329
345
|
let diffFlushPlatform = null;
|
|
330
346
|
|
|
@@ -339,14 +355,17 @@ export function createPresence(client, options = {}) {
|
|
|
339
355
|
}
|
|
340
356
|
entries.set(key, { op, data });
|
|
341
357
|
diffFlushPlatform = platform;
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
358
|
+
if (diffFlushTimer === null) {
|
|
359
|
+
diffFlushTimer = setTimeout(flushPendingDiffs, 0);
|
|
360
|
+
if (diffFlushTimer.unref) diffFlushTimer.unref();
|
|
345
361
|
}
|
|
346
362
|
}
|
|
347
363
|
|
|
348
364
|
function flushPendingDiffs() {
|
|
349
|
-
|
|
365
|
+
if (diffFlushTimer !== null) {
|
|
366
|
+
clearTimeout(diffFlushTimer);
|
|
367
|
+
diffFlushTimer = null;
|
|
368
|
+
}
|
|
350
369
|
const platform = diffFlushPlatform;
|
|
351
370
|
diffFlushPlatform = null;
|
|
352
371
|
if (!platform) {
|
|
@@ -363,7 +382,7 @@ export function createPresence(client, options = {}) {
|
|
|
363
382
|
else leaves[key] = data;
|
|
364
383
|
}
|
|
365
384
|
try {
|
|
366
|
-
platform.publish('__presence:' + topic, '
|
|
385
|
+
platform.publish('__presence:' + topic, 'diff', { joins, leaves }, { relay: false });
|
|
367
386
|
mDiffFrames?.inc({ topic: mt(topic) });
|
|
368
387
|
} catch { /* platform unavailable mid-flight */ }
|
|
369
388
|
}
|
|
@@ -533,11 +552,11 @@ export function createPresence(client, options = {}) {
|
|
|
533
552
|
// handler could only refresh `existing` entries; an
|
|
534
553
|
// entry the client swept (cross-replica relay latency,
|
|
535
554
|
// brief backpressure, JS thread saturation) could never
|
|
536
|
-
// be recovered without a
|
|
555
|
+
// be recovered without a diff for that user.
|
|
537
556
|
// Older clients fall back gracefully: they see an
|
|
538
557
|
// object instead of an array and skip the legacy
|
|
539
|
-
// "refresh-existing" branch, but the next
|
|
540
|
-
// or
|
|
558
|
+
// "refresh-existing" branch, but the next diff
|
|
559
|
+
// or state still reconciles them.
|
|
541
560
|
/** @type {Record<string, any>} */
|
|
542
561
|
const dataMap = {};
|
|
543
562
|
for (const [userKey, entry] of data) dataMap[userKey] = entry.data;
|
|
@@ -590,7 +609,7 @@ export function createPresence(client, options = {}) {
|
|
|
590
609
|
// The per-topic hash key expires only when every field has
|
|
591
610
|
// expired (no live instances presenting any user on this
|
|
592
611
|
// topic). That is the "whole topic empty" signal we forward
|
|
593
|
-
// as an empty
|
|
612
|
+
// as an empty state to local subscribers. Per-user
|
|
594
613
|
// hash keys (presence:user:{topic}:{userKey}) and the events
|
|
595
614
|
// channel are filtered out.
|
|
596
615
|
const topicPrefix = client.key('presence:topic:');
|
|
@@ -599,7 +618,7 @@ export function createPresence(client, options = {}) {
|
|
|
599
618
|
if (!expiredKey.startsWith(topicPrefix)) return;
|
|
600
619
|
const topic = expiredKey.slice(topicPrefix.length);
|
|
601
620
|
if (activePlatform) {
|
|
602
|
-
activePlatform.publish('__presence:' + topic, '
|
|
621
|
+
activePlatform.publish('__presence:' + topic, 'state', {}, { relay: false });
|
|
603
622
|
mKeyspaceCleanups?.inc();
|
|
604
623
|
}
|
|
605
624
|
});
|
|
@@ -1223,7 +1242,7 @@ export function createPresence(client, options = {}) {
|
|
|
1223
1242
|
state[userKey] = entry.data;
|
|
1224
1243
|
}
|
|
1225
1244
|
try {
|
|
1226
|
-
platform.send(ws, '__presence:' + topic, '
|
|
1245
|
+
platform.send(ws, '__presence:' + topic, 'state', state);
|
|
1227
1246
|
} catch {
|
|
1228
1247
|
// WebSocket closed before send
|
|
1229
1248
|
}
|
|
@@ -1270,7 +1289,7 @@ export function createPresence(client, options = {}) {
|
|
|
1270
1289
|
|
|
1271
1290
|
try {
|
|
1272
1291
|
ws.subscribe(presenceTopic);
|
|
1273
|
-
platform.send(ws, presenceTopic, '
|
|
1292
|
+
platform.send(ws, presenceTopic, 'state', state);
|
|
1274
1293
|
} catch {
|
|
1275
1294
|
const topics = syncObservers.get(ws);
|
|
1276
1295
|
if (topics && topics.has(topic)) {
|
|
@@ -1359,7 +1378,10 @@ export function createPresence(client, options = {}) {
|
|
|
1359
1378
|
syncObservers.clear();
|
|
1360
1379
|
syncCounts.clear();
|
|
1361
1380
|
pendingDiffs.clear();
|
|
1362
|
-
|
|
1381
|
+
if (diffFlushTimer !== null) {
|
|
1382
|
+
clearTimeout(diffFlushTimer);
|
|
1383
|
+
diffFlushTimer = null;
|
|
1384
|
+
}
|
|
1363
1385
|
diffFlushPlatform = null;
|
|
1364
1386
|
connCounter = 0;
|
|
1365
1387
|
},
|
|
@@ -1379,7 +1401,10 @@ export function createPresence(client, options = {}) {
|
|
|
1379
1401
|
keyspaceSubscribed = false;
|
|
1380
1402
|
activePlatform = null;
|
|
1381
1403
|
pendingDiffs.clear();
|
|
1382
|
-
|
|
1404
|
+
if (diffFlushTimer !== null) {
|
|
1405
|
+
clearTimeout(diffFlushTimer);
|
|
1406
|
+
diffFlushTimer = null;
|
|
1407
|
+
}
|
|
1383
1408
|
diffFlushPlatform = null;
|
|
1384
1409
|
},
|
|
1385
1410
|
|
|
@@ -1396,13 +1421,13 @@ export function createPresence(client, options = {}) {
|
|
|
1396
1421
|
// Client-initiated reconnect-snapshot. The presence plugin
|
|
1397
1422
|
// client sends `{type:'presence-snapshot', topic}` on every
|
|
1398
1423
|
// status==='open' (initial connect + reconnect). Re-emits
|
|
1399
|
-
// `
|
|
1424
|
+
// `state` to the requesting ws via `tracker.sync`,
|
|
1400
1425
|
// which is the same path that fires on a fresh subscribe.
|
|
1401
1426
|
// Symmetric to cursor's `cursor-snapshot` text frame.
|
|
1402
1427
|
//
|
|
1403
1428
|
// Without this, board-scoped presence stayed stale across
|
|
1404
1429
|
// reconnects: a tab that had joined via an RPC saw no
|
|
1405
|
-
//
|
|
1430
|
+
// diff during the disconnect window, and on
|
|
1406
1431
|
// reconnect its in-memory map was whatever it last knew.
|
|
1407
1432
|
// Global presence accidentally self-healed because most
|
|
1408
1433
|
// apps call `presence.join('global')` from the `open` hook
|
package/redis/pubsub.js
CHANGED
|
@@ -120,13 +120,25 @@ export function createPubSubBus(client, options = {}) {
|
|
|
120
120
|
/** @type {(() => void) | null} */
|
|
121
121
|
let unsubscribeBreaker = null;
|
|
122
122
|
|
|
123
|
-
//
|
|
124
|
-
// event-loop
|
|
125
|
-
// its underlying message count so the relayed-messages counter
|
|
126
|
-
// accurate when batch envelopes carry many messages each.
|
|
123
|
+
// Tick relay batching: coalesce Redis publishes within a single
|
|
124
|
+
// event-loop iteration into one pipelined round trip. Each envelope
|
|
125
|
+
// tracks its underlying message count so the relayed-messages counter
|
|
126
|
+
// stays accurate when batch envelopes carry many messages each.
|
|
127
|
+
//
|
|
128
|
+
// Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS
|
|
129
|
+
// message as its own JS task, and N-API drains microtasks at the C++/JS
|
|
130
|
+
// boundary between tasks. A microtask-deferred flush fires BEFORE the
|
|
131
|
+
// next socket's handler runs, so cross-socket coalescing is impossible
|
|
132
|
+
// at the microtask level - N publishes from N socket handlers in the
|
|
133
|
+
// same iteration produce N Redis round-trips instead of one pipelined
|
|
134
|
+
// call. `setTimeout(0)` lands in libuv's timers phase, which fires only
|
|
135
|
+
// after the poll phase has dispatched every ready socket message in the
|
|
136
|
+
// current iteration. Same structural choice the 0.5.7 cursor always-tick
|
|
137
|
+
// rewrite locked in.
|
|
127
138
|
/** @type {Array<{msg: string, count: number}>} */
|
|
128
139
|
let relayBatch = [];
|
|
129
|
-
|
|
140
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
141
|
+
let relayTimer = null;
|
|
130
142
|
let relayBatchWarnFired = false;
|
|
131
143
|
|
|
132
144
|
function scheduleRelay(msg, count) {
|
|
@@ -135,23 +147,26 @@ export function createPubSubBus(client, options = {}) {
|
|
|
135
147
|
if (relayBatch.length >= MAX_PUBSUB_RELAY_BATCH_PER_TICK && !relayBatchWarnFired) {
|
|
136
148
|
relayBatchWarnFired = true;
|
|
137
149
|
console.warn(
|
|
138
|
-
'[pubsub]
|
|
139
|
-
' entries in one
|
|
150
|
+
'[pubsub] tick relay batch reached ' + relayBatch.length +
|
|
151
|
+
' entries in one iteration. The batch is drained every tick, so a ' +
|
|
140
152
|
'caller emitted a million publishes in one synchronous burst - likely ' +
|
|
141
153
|
'a publish-in-loop without yielding.\n' +
|
|
142
154
|
' See: https://svti.me/pubsub-burst'
|
|
143
155
|
);
|
|
144
156
|
}
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
157
|
+
if (relayTimer === null) {
|
|
158
|
+
relayTimer = setTimeout(flushRelay, 0);
|
|
159
|
+
if (relayTimer.unref) relayTimer.unref();
|
|
148
160
|
}
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
function flushRelay() {
|
|
152
164
|
const batch = relayBatch;
|
|
153
165
|
relayBatch = [];
|
|
154
|
-
|
|
166
|
+
if (relayTimer !== null) {
|
|
167
|
+
clearTimeout(relayTimer);
|
|
168
|
+
relayTimer = null;
|
|
169
|
+
}
|
|
155
170
|
if (b) {
|
|
156
171
|
try { b.guard(); } catch { return; }
|
|
157
172
|
}
|
|
@@ -380,6 +395,11 @@ export function createPubSubBus(client, options = {}) {
|
|
|
380
395
|
unsubscribeBreaker();
|
|
381
396
|
unsubscribeBreaker = null;
|
|
382
397
|
}
|
|
398
|
+
if (relayTimer !== null) {
|
|
399
|
+
clearTimeout(relayTimer);
|
|
400
|
+
relayTimer = null;
|
|
401
|
+
}
|
|
402
|
+
relayBatch = [];
|
|
383
403
|
if (!active || !subscriber) return;
|
|
384
404
|
active = false;
|
|
385
405
|
activePlatform = null;
|
package/redis/sharded-pubsub.js
CHANGED
|
@@ -107,16 +107,26 @@ export function createShardedBus(client, options = {}) {
|
|
|
107
107
|
/** @type {WeakMap<any, Set<string>>} ws -> set of followed topics */
|
|
108
108
|
const wsFollows = new WeakMap();
|
|
109
109
|
|
|
110
|
-
// One-shot warn flag for the per-tick
|
|
110
|
+
// One-shot warn flag for the per-tick batch cap.
|
|
111
111
|
let batchChannelsWarnFired = false;
|
|
112
112
|
|
|
113
|
-
// Per-channel
|
|
114
|
-
//
|
|
115
|
-
// tracks the underlying topic list so the relayed-messages
|
|
116
|
-
// stays accurate when batch envelopes carry many messages.
|
|
113
|
+
// Per-channel tick batch: coalesce SPUBLISHes for the same channel
|
|
114
|
+
// within one event-loop iteration into a single pipelined call. Each
|
|
115
|
+
// entry tracks the underlying topic list so the relayed-messages
|
|
116
|
+
// counter stays accurate when batch envelopes carry many messages.
|
|
117
|
+
//
|
|
118
|
+
// Why `setTimeout(0)` and not `queueMicrotask`: uWS dispatches each WS
|
|
119
|
+
// message as its own JS task, and N-API drains microtasks at the C++/JS
|
|
120
|
+
// boundary between tasks. A microtask-deferred flush fires BEFORE the
|
|
121
|
+
// next socket's handler runs, so cross-socket coalescing is impossible
|
|
122
|
+
// at the microtask level. `setTimeout(0)` lands in libuv's timers
|
|
123
|
+
// phase, which fires only after the poll phase has dispatched every
|
|
124
|
+
// ready socket message in the current iteration. Same structural
|
|
125
|
+
// choice the 0.5.7 cursor always-tick rewrite locked in.
|
|
117
126
|
/** @type {Map<string, Array<{msg: string, topics: string[]}>>} */
|
|
118
127
|
let channelBatches = new Map();
|
|
119
|
-
|
|
128
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
129
|
+
let relayTimer = null;
|
|
120
130
|
|
|
121
131
|
function scheduleRelay(channel, msg, topics) {
|
|
122
132
|
let arr = channelBatches.get(channel);
|
|
@@ -128,23 +138,26 @@ export function createShardedBus(client, options = {}) {
|
|
|
128
138
|
if (channelBatches.size >= MAX_SHARDED_BUS_BATCH_CHANNELS_PER_TICK && !batchChannelsWarnFired) {
|
|
129
139
|
batchChannelsWarnFired = true;
|
|
130
140
|
console.warn(
|
|
131
|
-
'[sharded-bus]
|
|
132
|
-
' distinct channels in one
|
|
141
|
+
'[sharded-bus] tick batch reached ' + channelBatches.size +
|
|
142
|
+
' distinct channels in one iteration. The batch is drained every tick, ' +
|
|
133
143
|
'so reaching this size means a publisher emitted a million distinct ' +
|
|
134
144
|
'channels in one synchronous burst - likely a topic-cardinality leak.\n' +
|
|
135
145
|
' See: https://svti.me/sharded-bus-burst'
|
|
136
146
|
);
|
|
137
147
|
}
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
if (relayTimer === null) {
|
|
149
|
+
relayTimer = setTimeout(flushRelay, 0);
|
|
150
|
+
if (relayTimer.unref) relayTimer.unref();
|
|
141
151
|
}
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
function flushRelay() {
|
|
145
155
|
const batches = channelBatches;
|
|
146
156
|
channelBatches = new Map();
|
|
147
|
-
|
|
157
|
+
if (relayTimer !== null) {
|
|
158
|
+
clearTimeout(relayTimer);
|
|
159
|
+
relayTimer = null;
|
|
160
|
+
}
|
|
148
161
|
if (b) {
|
|
149
162
|
try { b.guard(); } catch { return; }
|
|
150
163
|
}
|
|
@@ -268,7 +281,10 @@ export function createShardedBus(client, options = {}) {
|
|
|
268
281
|
subscriber = null;
|
|
269
282
|
}
|
|
270
283
|
channelBatches = new Map();
|
|
271
|
-
|
|
284
|
+
if (relayTimer !== null) {
|
|
285
|
+
clearTimeout(relayTimer);
|
|
286
|
+
relayTimer = null;
|
|
287
|
+
}
|
|
272
288
|
followCounts.clear();
|
|
273
289
|
channelRefcounts.clear();
|
|
274
290
|
}
|