@pylonsync/sync 0.3.178 → 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 +73 -34
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -716,6 +716,17 @@ export class SyncEngine {
|
|
|
716
716
|
private wsStableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
717
717
|
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
718
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();
|
|
729
|
+
|
|
719
730
|
/**
|
|
720
731
|
* Registered consumers for binary WebSocket frames. SyncEngine itself
|
|
721
732
|
* doesn't decode binary — it just owns the WS connection and routes
|
|
@@ -965,6 +976,53 @@ export class SyncEngine {
|
|
|
965
976
|
|
|
966
977
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
967
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
|
+
|
|
968
1026
|
private startPolling(): void {
|
|
969
1027
|
const interval = this.config.pollInterval ?? 1000;
|
|
970
1028
|
this.pollTimer = setInterval(() => {
|
|
@@ -1118,18 +1176,14 @@ export class SyncEngine {
|
|
|
1118
1176
|
try {
|
|
1119
1177
|
const msg = JSON.parse(event.data as string);
|
|
1120
1178
|
|
|
1121
|
-
// Sync change event. Persist BEFORE advancing the cursor so a
|
|
1122
|
-
// 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.
|
|
1123
1184
|
if (msg.seq && msg.entity && msg.kind) {
|
|
1124
1185
|
const change = msg as ChangeEvent;
|
|
1125
|
-
|
|
1126
|
-
void this.store.applyChangesAsync([change]).then(async () => {
|
|
1127
|
-
this.cursor = { last_seq: change.seq };
|
|
1128
|
-
if (this.persistence) {
|
|
1129
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1130
|
-
}
|
|
1131
|
-
});
|
|
1132
|
-
}
|
|
1186
|
+
void this.enqueueApply([change]);
|
|
1133
1187
|
return;
|
|
1134
1188
|
}
|
|
1135
1189
|
|
|
@@ -1259,14 +1313,7 @@ export class SyncEngine {
|
|
|
1259
1313
|
const msg = JSON.parse(event.data);
|
|
1260
1314
|
if (msg.seq && msg.entity && msg.kind) {
|
|
1261
1315
|
const change = msg as ChangeEvent;
|
|
1262
|
-
|
|
1263
|
-
void this.store.applyChangesAsync([change]).then(async () => {
|
|
1264
|
-
this.cursor = { last_seq: change.seq };
|
|
1265
|
-
if (this.persistence) {
|
|
1266
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1267
|
-
}
|
|
1268
|
-
});
|
|
1269
|
-
}
|
|
1316
|
+
void this.enqueueApply([change]);
|
|
1270
1317
|
}
|
|
1271
1318
|
} catch {
|
|
1272
1319
|
// Ignore malformed events.
|
|
@@ -1480,22 +1527,14 @@ export class SyncEngine {
|
|
|
1480
1527
|
);
|
|
1481
1528
|
// Successful response — clear the 410 circuit breaker.
|
|
1482
1529
|
this.consecutive_410s = 0;
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
|
|
1491
|
-
// "non-empty" responses pins the client at `since=0` forever and turns
|
|
1492
|
-
// every reconnect into another pull for the same empty window.
|
|
1493
|
-
if (resp.cursor && resp.cursor.last_seq > this.cursor.last_seq) {
|
|
1494
|
-
this.cursor = resp.cursor;
|
|
1495
|
-
if (this.persistence) {
|
|
1496
|
-
await this.persistence.saveCursor(this.cursor);
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
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);
|
|
1499
1538
|
// If there are more, pull again immediately.
|
|
1500
1539
|
if (resp.has_more) {
|
|
1501
1540
|
await this.pull();
|