@pylonsync/sync 0.3.177 → 0.3.179
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 +1 -1
- package/src/index.ts +103 -34
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -714,6 +714,18 @@ export class SyncEngine {
|
|
|
714
714
|
* the client can't hammer the server on auth failures.
|
|
715
715
|
*/
|
|
716
716
|
private wsStableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
717
|
+
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Serialized apply queue. Every change-event apply — from WS onmessage,
|
|
721
|
+
* pull(), or session-changed catchup — chains onto this promise so
|
|
722
|
+
* applies execute in arrival order. Without this, two WS messages or
|
|
723
|
+
* two concurrent pull()s race: seq 3's persistence can land before
|
|
724
|
+
* seq 2's, leaving the row at the older value AND the cursor briefly
|
|
725
|
+
* regressing if writes complete out of order. The queue also gates
|
|
726
|
+
* the cursor advance so `last_seq` only moves forward.
|
|
727
|
+
*/
|
|
728
|
+
private applyQueue: Promise<void> = Promise.resolve();
|
|
717
729
|
|
|
718
730
|
/**
|
|
719
731
|
* Registered consumers for binary WebSocket frames. SyncEngine itself
|
|
@@ -964,6 +976,53 @@ export class SyncEngine {
|
|
|
964
976
|
|
|
965
977
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
966
978
|
|
|
979
|
+
/**
|
|
980
|
+
* Serialize a batch of change applies behind any in-flight applies, and
|
|
981
|
+
* advance the cursor monotonically when the batch lands. Both the WS
|
|
982
|
+
* onmessage path and pull() funnel through here so seq 3's persistence
|
|
983
|
+
* can't race ahead of seq 2's. The returned promise resolves after
|
|
984
|
+
* THIS batch is applied (not after later batches), so a caller awaiting
|
|
985
|
+
* pull() still completes deterministically.
|
|
986
|
+
*
|
|
987
|
+
* Per-event monotonic filter: re-applies of an already-seen seq are
|
|
988
|
+
* skipped before touching the store. Without that, a retransmit
|
|
989
|
+
* (WS + pull window overlap) would have us run applyChange twice
|
|
990
|
+
* against the local store.
|
|
991
|
+
*/
|
|
992
|
+
private enqueueApply(
|
|
993
|
+
changes: ChangeEvent[],
|
|
994
|
+
targetCursor?: SyncCursor,
|
|
995
|
+
): Promise<void> {
|
|
996
|
+
const prev = this.applyQueue;
|
|
997
|
+
const next = prev.then(async () => {
|
|
998
|
+
const filtered = changes.filter(
|
|
999
|
+
(c) => typeof c.seq === "number" && c.seq > this.cursor.last_seq,
|
|
1000
|
+
);
|
|
1001
|
+
if (filtered.length > 0) {
|
|
1002
|
+
await this.store.applyChangesAsync(filtered);
|
|
1003
|
+
}
|
|
1004
|
+
// Pick the cursor target. Explicit `targetCursor` (from pull) wins
|
|
1005
|
+
// — pull's response carries the server's authoritative current_seq
|
|
1006
|
+
// even when no changes landed in this window. Otherwise derive
|
|
1007
|
+
// from the last applied seq.
|
|
1008
|
+
const candidate =
|
|
1009
|
+
targetCursor ??
|
|
1010
|
+
(filtered.length > 0
|
|
1011
|
+
? { last_seq: filtered[filtered.length - 1].seq }
|
|
1012
|
+
: null);
|
|
1013
|
+
if (candidate && candidate.last_seq > this.cursor.last_seq) {
|
|
1014
|
+
this.cursor = candidate;
|
|
1015
|
+
if (this.persistence) {
|
|
1016
|
+
await this.persistence.saveCursor(this.cursor);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
// Errors stay scoped to this batch — don't poison the chain for
|
|
1021
|
+
// future applies.
|
|
1022
|
+
this.applyQueue = next.catch(() => {});
|
|
1023
|
+
return next;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
967
1026
|
private startPolling(): void {
|
|
968
1027
|
const interval = this.config.pollInterval ?? 1000;
|
|
969
1028
|
this.pollTimer = setInterval(() => {
|
|
@@ -986,6 +1045,10 @@ export class SyncEngine {
|
|
|
986
1045
|
clearInterval(this.pollTimer);
|
|
987
1046
|
this.pollTimer = null;
|
|
988
1047
|
}
|
|
1048
|
+
if (this.pingTimer) {
|
|
1049
|
+
clearInterval(this.pingTimer);
|
|
1050
|
+
this.pingTimer = null;
|
|
1051
|
+
}
|
|
989
1052
|
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
990
1053
|
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
991
1054
|
this.visibilityHandler = null;
|
|
@@ -1033,6 +1096,27 @@ export class SyncEngine {
|
|
|
1033
1096
|
this.reconnectAttempts = 0;
|
|
1034
1097
|
this.wsStableTimer = null;
|
|
1035
1098
|
}, 5_000);
|
|
1099
|
+
// Client-side keepalive ping. The server's per-client reader
|
|
1100
|
+
// thread blocks on a synchronous WS read for as long as no client
|
|
1101
|
+
// message arrives. On the HTTP-multiplexed `/api/sync/ws` path
|
|
1102
|
+
// tiny_http doesn't expose stream-level read timeouts, so the
|
|
1103
|
+
// reader's mutex hold is bounded only by client activity. Without
|
|
1104
|
+
// these pings the broadcaster contends for the same mutex and
|
|
1105
|
+
// wedges — Insert events never reach the tab. A 1s cadence makes
|
|
1106
|
+
// worst-case broadcast latency ~1s even when the user is idle.
|
|
1107
|
+
// Browsers don't expose WebSocket-level PING frames; a JSON
|
|
1108
|
+
// payload with type:"ping" achieves the same effect (the server
|
|
1109
|
+
// reads, looks up an unknown "ping" type, loops back releasing
|
|
1110
|
+
// the mutex; broadcaster grabs it during the gap).
|
|
1111
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
1112
|
+
this.pingTimer = setInterval(() => {
|
|
1113
|
+
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
1114
|
+
try {
|
|
1115
|
+
this.ws.send('{"type":"ping"}');
|
|
1116
|
+
} catch {
|
|
1117
|
+
// ignore — onclose will trigger reconnect
|
|
1118
|
+
}
|
|
1119
|
+
}, 1_000);
|
|
1036
1120
|
// Re-send any active CRDT subscriptions across the new socket.
|
|
1037
1121
|
// The server purged them on disconnect (`unsubscribe_all`), so
|
|
1038
1122
|
// without this resync a tab that was subscribed before a network
|
|
@@ -1092,18 +1176,14 @@ export class SyncEngine {
|
|
|
1092
1176
|
try {
|
|
1093
1177
|
const msg = JSON.parse(event.data as string);
|
|
1094
1178
|
|
|
1095
|
-
// Sync change event. Persist BEFORE advancing the cursor so a
|
|
1096
|
-
// can't leave `last_seq` ahead of the replica on disk.
|
|
1179
|
+
// Sync change event. Persist BEFORE advancing the cursor so a
|
|
1180
|
+
// crash can't leave `last_seq` ahead of the replica on disk.
|
|
1181
|
+
// The shared apply queue serializes WS messages with each other
|
|
1182
|
+
// AND with concurrent pull() calls, so seq order is preserved
|
|
1183
|
+
// and the cursor only advances monotonically.
|
|
1097
1184
|
if (msg.seq && msg.entity && msg.kind) {
|
|
1098
1185
|
const change = msg as ChangeEvent;
|
|
1099
|
-
|
|
1100
|
-
void this.store.applyChangesAsync([change]).then(async () => {
|
|
1101
|
-
this.cursor = { last_seq: change.seq };
|
|
1102
|
-
if (this.persistence) {
|
|
1103
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1104
|
-
}
|
|
1105
|
-
});
|
|
1106
|
-
}
|
|
1186
|
+
void this.enqueueApply([change]);
|
|
1107
1187
|
return;
|
|
1108
1188
|
}
|
|
1109
1189
|
|
|
@@ -1162,6 +1242,10 @@ export class SyncEngine {
|
|
|
1162
1242
|
clearTimeout(this.wsStableTimer);
|
|
1163
1243
|
this.wsStableTimer = null;
|
|
1164
1244
|
}
|
|
1245
|
+
if (this.pingTimer) {
|
|
1246
|
+
clearInterval(this.pingTimer);
|
|
1247
|
+
this.pingTimer = null;
|
|
1248
|
+
}
|
|
1165
1249
|
// Surface the disconnect to UI consumers immediately. If
|
|
1166
1250
|
// `running` flipped to false (engine stopped), `stop()` already
|
|
1167
1251
|
// set "offline" — don't override that.
|
|
@@ -1229,14 +1313,7 @@ export class SyncEngine {
|
|
|
1229
1313
|
const msg = JSON.parse(event.data);
|
|
1230
1314
|
if (msg.seq && msg.entity && msg.kind) {
|
|
1231
1315
|
const change = msg as ChangeEvent;
|
|
1232
|
-
|
|
1233
|
-
void this.store.applyChangesAsync([change]).then(async () => {
|
|
1234
|
-
this.cursor = { last_seq: change.seq };
|
|
1235
|
-
if (this.persistence) {
|
|
1236
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1237
|
-
}
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1316
|
+
void this.enqueueApply([change]);
|
|
1240
1317
|
}
|
|
1241
1318
|
} catch {
|
|
1242
1319
|
// Ignore malformed events.
|
|
@@ -1450,22 +1527,14 @@ export class SyncEngine {
|
|
|
1450
1527
|
);
|
|
1451
1528
|
// Successful response — clear the 410 circuit breaker.
|
|
1452
1529
|
this.consecutive_410s = 0;
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
|
|
1461
|
-
// "non-empty" responses pins the client at `since=0` forever and turns
|
|
1462
|
-
// every reconnect into another pull for the same empty window.
|
|
1463
|
-
if (resp.cursor && resp.cursor.last_seq > this.cursor.last_seq) {
|
|
1464
|
-
this.cursor = resp.cursor;
|
|
1465
|
-
if (this.persistence) {
|
|
1466
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1530
|
+
// Route through the apply queue so concurrent WS messages and
|
|
1531
|
+
// pull responses don't race. The queue's monotonic guard skips
|
|
1532
|
+
// any seq we've already applied (e.g. WS already landed the
|
|
1533
|
+
// events that pull is now redelivering) and only advances the
|
|
1534
|
+
// cursor forward. We still advance even when no changes land,
|
|
1535
|
+
// because the server's last_seq may have moved past us due to
|
|
1536
|
+
// policy-filtered events.
|
|
1537
|
+
await this.enqueueApply(resp.changes, resp.cursor);
|
|
1469
1538
|
// If there are more, pull again immediately.
|
|
1470
1539
|
if (resp.has_more) {
|
|
1471
1540
|
await this.pull();
|