svelte-adapter-uws-extensions 0.4.0 → 0.4.1
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/README.md +42 -3
- package/package.json +1 -1
- package/redis/replay.js +15 -1
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ The core adapter keeps everything in-process memory. That works great for single
|
|
|
42
42
|
- [Prometheus metrics](#prometheus-metrics)
|
|
43
43
|
|
|
44
44
|
**Reliability**
|
|
45
|
+
- [Failure handling](#failure-handling)
|
|
45
46
|
- [Circuit breaker](#circuit-breaker)
|
|
46
47
|
|
|
47
48
|
**Operations**
|
|
@@ -144,7 +145,9 @@ export const pg = createPgClient({
|
|
|
144
145
|
|
|
145
146
|
## Pub/sub bus
|
|
146
147
|
|
|
147
|
-
Distributes `platform.publish()` calls across multiple server instances via Redis pub/sub. Each instance publishes locally AND to Redis. Incoming Redis messages are forwarded to the local platform with echo suppression (messages from the same instance are
|
|
148
|
+
Distributes `platform.publish()` calls across multiple server instances via Redis pub/sub. Each instance publishes locally AND to Redis. Incoming Redis messages are forwarded to the local platform with echo suppression (messages originating from the same instance are dropped on receive, keyed by a per-process instance ID).
|
|
149
|
+
|
|
150
|
+
Multiple `publish()` calls within the same event-loop tick are coalesced into a single Redis pipeline via microtask batching. This means a form action that publishes to three topics results in one pipelined round trip, not three independent commands.
|
|
148
151
|
|
|
149
152
|
#### Setup
|
|
150
153
|
|
|
@@ -198,6 +201,10 @@ export function message(ws, { data, platform }) {
|
|
|
198
201
|
|
|
199
202
|
Same API as the core `createReplay` plugin, but backed by Redis sorted sets. Messages survive restarts and are shared across instances.
|
|
200
203
|
|
|
204
|
+
Sequence numbers are incremented atomically via a Lua script (`INCR` + `ZADD` + trim in a single `EVAL`), so concurrent publishes from multiple instances produce strictly ordered, gap-free sequences per topic. When the buffer exceeds `size`, the oldest entries are removed inside the same Lua script -- no second round trip required.
|
|
205
|
+
|
|
206
|
+
When a client requests replay, the buffer checks whether the client's last-seen sequence is older than the oldest buffered entry. If it is (the buffer was trimmed past the client's position), a `truncated` event fires on `__replay:{topic}` before any `msg` events, so the client knows it missed messages and can do a full reload. This also fires when the buffer is completely empty but the sequence counter has advanced past the client's position (e.g. all entries expired via TTL).
|
|
207
|
+
|
|
201
208
|
#### Setup
|
|
202
209
|
|
|
203
210
|
```js
|
|
@@ -269,6 +276,12 @@ All methods are async (they hit Redis). The API otherwise matches the core plugi
|
|
|
269
276
|
|
|
270
277
|
Same API as the core `createPresence` plugin, but backed by Redis hashes. Presence state is shared across instances with cross-instance join/leave notifications via Redis pub/sub.
|
|
271
278
|
|
|
279
|
+
Joins are staged with full rollback on failure: local state is set up first, then the Redis hash field is 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 field, and any broadcast join event are reversed. This prevents ghost entries that would show a user as online when they never fully connected.
|
|
280
|
+
|
|
281
|
+
Leaves use an atomic Lua script (`LEAVE_SCRIPT`) that removes this instance's field from the hash and then scans remaining fields for the same user key, ignoring stale entries. Leave is only broadcast when no other instance holds a live entry for that user, preventing premature "user left" notifications in multi-instance deployments.
|
|
282
|
+
|
|
283
|
+
Zombie cleanup runs on the heartbeat interval. Each tick, every tracked WebSocket is probed via `getBufferedAmount()` -- if the call throws, the socket is dead and its presence is removed synchronously before the heartbeat writes to Redis. The heartbeat then refreshes timestamps on all live entries via a Redis pipeline and runs a server-side Lua cleanup script (`CLEANUP_SCRIPT`) that scans the hash and removes any fields whose timestamp exceeds the TTL. This handles crashed instances whose close handlers never fired.
|
|
284
|
+
|
|
272
285
|
#### Setup
|
|
273
286
|
|
|
274
287
|
```js
|
|
@@ -527,7 +540,11 @@ export function close(ws, { platform }) {
|
|
|
527
540
|
|
|
528
541
|
## Replay buffer (Postgres)
|
|
529
542
|
|
|
530
|
-
Same API as the Redis replay buffer, but backed by a Postgres table. Best suited for durable audit trails or history that needs to survive longer than Redis TTLs. Sequence numbers are generated atomically via a dedicated `_seq` table
|
|
543
|
+
Same API as the Redis replay buffer, but backed by a Postgres table. Best suited for durable audit trails or history that needs to survive longer than Redis TTLs. Sequence numbers are generated atomically via a dedicated `_seq` table using `INSERT ... ON CONFLICT DO UPDATE`, so concurrent publishes from multiple instances produce strictly ordered sequences with no duplicates or gaps.
|
|
544
|
+
|
|
545
|
+
Buffer trimming runs after each publish by deleting rows with `seq <= currentSeq - maxSize`. If the trim query fails, the publish still succeeds -- the periodic background cleanup (configurable via `cleanupInterval`) catches any excess rows later.
|
|
546
|
+
|
|
547
|
+
Same gap detection behavior as the Redis replay buffer: if the client's last-seen sequence is older than the oldest buffered row, or the buffer is empty but the sequence counter has advanced, a `truncated` event fires before replay.
|
|
531
548
|
|
|
532
549
|
#### Setup
|
|
533
550
|
|
|
@@ -669,10 +686,14 @@ The client side needs no changes -- the core `crud('messages')` store already ha
|
|
|
669
686
|
|
|
670
687
|
#### Limitations
|
|
671
688
|
|
|
672
|
-
- Payload is limited to
|
|
689
|
+
- **Payload is hard-limited to ~8000 bytes by Postgres** (`pg_notify` silently truncates or errors above this). This is a Postgres constraint, not a library limitation. The bridge warns at 7500 bytes. For large rows, send the row ID in the notification and let the client fetch the full row via an API call.
|
|
673
690
|
- Only fires from triggers. Changes made outside your app (manual SQL, migrations) are invisible unless you add triggers for those tables too.
|
|
674
691
|
- This is not logical replication. It is simpler, works on every Postgres provider, and needs no extensions or superuser access.
|
|
675
692
|
|
|
693
|
+
#### When to use this instead of Redis pub/sub
|
|
694
|
+
|
|
695
|
+
If your real-time events are driven by database writes and you do not need Redis for other extensions (presence, rate limiting, groups, cursors), the LISTEN/NOTIFY bridge is a simpler deployment: no Redis infrastructure, no separate pub/sub channel management, and your notifications are inherently tied to committed transactions. Use the Redis pub/sub bus when you need to broadcast events that do not originate from database writes, or when you are already running Redis for other extensions.
|
|
696
|
+
|
|
676
697
|
---
|
|
677
698
|
|
|
678
699
|
**Observability**
|
|
@@ -812,6 +833,24 @@ const metrics = createMetrics({
|
|
|
812
833
|
|
|
813
834
|
**Reliability**
|
|
814
835
|
|
|
836
|
+
## Failure handling
|
|
837
|
+
|
|
838
|
+
Every Redis and Postgres extension accepts an optional `breaker` option -- a shared [circuit breaker](#circuit-breaker) that tracks backend health across all extensions wired to it. When the breaker trips, each extension degrades differently depending on whether the operation is critical or best-effort:
|
|
839
|
+
|
|
840
|
+
| Extension | Awaited operations (join, consume, publish) | Fire-and-forget operations |
|
|
841
|
+
|---|---|---|
|
|
842
|
+
| **Pub/sub bus** | `wrap().publish()` queues to local platform only; relay to Redis is skipped silently | Microtask relay flush is skipped entirely |
|
|
843
|
+
| **Presence** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh and stale cleanup are skipped |
|
|
844
|
+
| **Replay buffer** | `publish()` / `replay()` / `seq()` throw `CircuitBrokenError` | -- |
|
|
845
|
+
| **Rate limiting** | `consume()` throws `CircuitBrokenError` (fail-closed -- requests are blocked, not allowed through) | -- |
|
|
846
|
+
| **Broadcast groups** | `join()` / `leave()` throw `CircuitBrokenError` | Heartbeat refresh is skipped |
|
|
847
|
+
| **Cursor** | -- | Hash writes and cross-instance relay are skipped; local throttle continues |
|
|
848
|
+
| **LISTEN/NOTIFY** | `activate()` throws; auto-reconnect retries on its own interval | -- |
|
|
849
|
+
|
|
850
|
+
The breaker is a three-state machine: **healthy** (all requests pass through) -> **broken** after N consecutive failures (all requests fail fast via `CircuitBrokenError`) -> **probing** after a timeout (one request is allowed through to test recovery) -> back to **healthy** on success. See [Circuit breaker](#circuit-breaker) for configuration.
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
815
854
|
## Circuit breaker
|
|
816
855
|
|
|
817
856
|
Prevents thundering herd when a backend goes down. When Redis or Postgres becomes unreachable, every extension that uses the breaker fails fast instead of queueing up timeouts, and fire-and-forget operations (heartbeats, relay flushes, cursor broadcasts) are skipped entirely.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws-extensions",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
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/replay.js
CHANGED
|
@@ -164,7 +164,6 @@ export function createReplay(client, options = {}) {
|
|
|
164
164
|
let rawAll;
|
|
165
165
|
try {
|
|
166
166
|
rawAll = await redis.zrangebyscore(bufKey(topic), '-inf', '+inf');
|
|
167
|
-
b?.success();
|
|
168
167
|
} catch (err) {
|
|
169
168
|
b?.failure(err);
|
|
170
169
|
throw err;
|
|
@@ -192,6 +191,21 @@ export function createReplay(client, options = {}) {
|
|
|
192
191
|
} catch { /* skip corrupted */ }
|
|
193
192
|
}
|
|
194
193
|
|
|
194
|
+
if (oldestSeq === null && sinceSeq > 0 && missed.length === 0) {
|
|
195
|
+
try {
|
|
196
|
+
const val = await redis.get(seqKey(topic));
|
|
197
|
+
const currentSeq = val ? parseInt(val, 10) : 0;
|
|
198
|
+
if (currentSeq > sinceSeq) {
|
|
199
|
+
mTruncations?.inc({ topic: mt(topic) });
|
|
200
|
+
platform.send(ws, replayTopic, 'truncated', null);
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
b?.failure(err);
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
b?.success();
|
|
208
|
+
|
|
195
209
|
for (let i = 0; i < missed.length; i++) {
|
|
196
210
|
const msg = missed[i];
|
|
197
211
|
platform.send(ws, replayTopic, 'msg', {
|