@pylonsync/sync 0.3.363 → 0.3.365
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/dist/test-harness/server.d.ts +5 -0
- package/package.json +1 -1
- package/src/index.ts +48 -23
- package/src/scenarios.test.ts +68 -0
- package/src/test-harness/server.ts +5 -0
- package/src/test-harness/transport.ts +17 -0
|
@@ -73,6 +73,11 @@ export declare class TestServer {
|
|
|
73
73
|
/** Count of snapshot pulls served (since = 0). The egress storm was a
|
|
74
74
|
* runaway count here; the regression test bounds it. */
|
|
75
75
|
snapshotPullCount: number;
|
|
76
|
+
/** When set, delta pulls (since > 0) page their response to this many
|
|
77
|
+
* events per request with a real per-page cursor + has_more — models
|
|
78
|
+
* the production DELTA_BATCH_LIMIT so catch-up pagination and
|
|
79
|
+
* fetch/apply pipelining can be exercised. */
|
|
80
|
+
deltaPageSize: number | null;
|
|
76
81
|
/** Count of /api/sync/push requests received. Lets a test assert the
|
|
77
82
|
* engine actually shipped a batch (e.g. hydrated offline writes that
|
|
78
83
|
* must drain once leader-elected), independent of the no-op push
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1714,16 +1714,40 @@ export class SyncEngine {
|
|
|
1714
1714
|
// has_more, 410 recursion) run at a non-zero cursor → they don't touch
|
|
1715
1715
|
// this, and their applies pass `isPull` so they're never held.
|
|
1716
1716
|
if (startedFromZero) this.snapshotHold = [];
|
|
1717
|
+
// Declared outside the try: on a mid-loop fetch error the previous
|
|
1718
|
+
// page's apply may still be in flight, and the catch (410 reset →
|
|
1719
|
+
// re-pull) MUST let it settle first — otherwise the orphaned apply
|
|
1720
|
+
// races the replica wipe and re-writes stale rows into the fresh
|
|
1721
|
+
// store (and drags the cursor forward under the recursive pull).
|
|
1722
|
+
let pendingApply: Promise<void> | null = null;
|
|
1717
1723
|
try {
|
|
1718
1724
|
// Snapshot pagination: when the cursor is 0 and the server's
|
|
1719
1725
|
// table is larger than a single batch, the response carries
|
|
1720
|
-
// `snapshot_after` for the next page.
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
// for the whole
|
|
1726
|
+
// `snapshot_after` for the next page. The change-log tail
|
|
1727
|
+
// paginates via `has_more`. Both loop until exhausted BEFORE
|
|
1728
|
+
// returning so a fresh client always observes a consistent full
|
|
1729
|
+
// snapshot (not a 1k-row prefix it mistakes for the whole
|
|
1730
|
+
// replica) and a catching-up client drains the whole tail.
|
|
1731
|
+
//
|
|
1732
|
+
// Fetch/apply pipelining: applying a page (IndexedDB writes) and
|
|
1733
|
+
// fetching the next one are independent, so page N's apply runs
|
|
1734
|
+
// WHILE page N+1 is in flight — catch-up latency is
|
|
1735
|
+
// max(network, apply) per page instead of their sum. Ordering is
|
|
1736
|
+
// safe because enqueueApply chains every batch onto the same
|
|
1737
|
+
// applyQueue in call order; page N+1 is only ENQUEUED after page
|
|
1738
|
+
// N's apply resolved, so a failed apply aborts the loop instead
|
|
1739
|
+
// of letting later pages advance the cursor over a hole.
|
|
1740
|
+
//
|
|
1741
|
+
// The next `since` comes from each RESPONSE's cursor, not
|
|
1742
|
+
// `this.cursor` — the apply is what advances `this.cursor`, and
|
|
1743
|
+
// waiting on it is exactly what pipelining removes. Mid-snapshot
|
|
1744
|
+
// the server pins the response cursor at 0, which keeps routing
|
|
1745
|
+
// to the snapshot path.
|
|
1724
1746
|
let snapshotAfter: string | undefined;
|
|
1747
|
+
let hasMore = false;
|
|
1725
1748
|
let firstPass = true;
|
|
1726
|
-
|
|
1749
|
+
let nextSince = this.cursor.last_seq;
|
|
1750
|
+
while (firstPass || snapshotAfter || hasMore) {
|
|
1727
1751
|
firstPass = false;
|
|
1728
1752
|
// `snapshot_after` is an OPAQUE cursor the server already URL-encoded
|
|
1729
1753
|
// (it `url_encode`s the JSON payload). It MUST be appended raw — running
|
|
@@ -1733,33 +1757,28 @@ export class SyncEngine {
|
|
|
1733
1757
|
// restart the snapshot from row 0 — an infinite re-snapshot loop for any
|
|
1734
1758
|
// table larger than one page (SNAPSHOT_BATCH_LIMIT rows). `since` is a
|
|
1735
1759
|
// plain integer, so it's safe to inline.
|
|
1736
|
-
let query = `since=${
|
|
1760
|
+
let query = `since=${nextSince}`;
|
|
1737
1761
|
if (snapshotAfter) {
|
|
1738
1762
|
query += `&snapshot_after=${snapshotAfter}`;
|
|
1739
1763
|
}
|
|
1740
1764
|
const resp = await this.request<
|
|
1741
1765
|
PullResponse & { snapshot_after?: string | null }
|
|
1742
1766
|
>("GET", `/api/sync/pull?${query}`);
|
|
1743
|
-
|
|
1767
|
+
if (pendingApply) await pendingApply;
|
|
1768
|
+
pendingApply = this.enqueueApply(resp.changes, resp.cursor, {
|
|
1769
|
+
isPull: true,
|
|
1770
|
+
});
|
|
1771
|
+
// Guard against a server that reports more pages without
|
|
1772
|
+
// advancing the cursor (a transient no-progress page): break
|
|
1773
|
+
// rather than refetch the same page forever. The next poll /
|
|
1774
|
+
// change event re-drives the pull.
|
|
1775
|
+
const advanced = resp.cursor.last_seq > nextSince;
|
|
1776
|
+
nextSince = resp.cursor.last_seq;
|
|
1744
1777
|
// `snapshot_after` is only set when the server is mid-snapshot.
|
|
1745
|
-
// Continue paginating in the same loop iteration so we don't
|
|
1746
|
-
// leave a fresh client with a partial replica.
|
|
1747
1778
|
snapshotAfter = resp.snapshot_after ?? undefined;
|
|
1748
|
-
|
|
1749
|
-
// by recursing into `pullInner` directly. We are INSIDE the
|
|
1750
|
-
// `pull` op-queue slot right now; calling the public `pull()`
|
|
1751
|
-
// would re-enqueue under the same "pull" key, which coalesces
|
|
1752
|
-
// to the promise we're currently running inside (op-queue.ts
|
|
1753
|
-
// deletes the key only after `fn` resolves) and `await` it →
|
|
1754
|
-
// permanent self-deadlock that bricks the entire pull path for
|
|
1755
|
-
// the session. This is the exact hazard the 410 handler avoids;
|
|
1756
|
-
// `pullInner` re-reads `this.cursor.last_seq` (already advanced
|
|
1757
|
-
// by enqueueApply) so the recursion resumes at the right cursor.
|
|
1758
|
-
if (!snapshotAfter && resp.has_more) {
|
|
1759
|
-
await this.pullInner();
|
|
1760
|
-
break;
|
|
1761
|
-
}
|
|
1779
|
+
hasMore = !snapshotAfter && resp.has_more && advanced;
|
|
1762
1780
|
}
|
|
1781
|
+
if (pendingApply) await pendingApply;
|
|
1763
1782
|
// Clear the resync circuit breaker ONLY on a successful DELTA
|
|
1764
1783
|
// pull — one that started from a real, non-zero cursor the server
|
|
1765
1784
|
// honored. A snapshot pull from cursor=0 succeeding does NOT prove
|
|
@@ -1795,6 +1814,12 @@ export class SyncEngine {
|
|
|
1795
1814
|
if (held.length > 0) await this.enqueueApply(held);
|
|
1796
1815
|
}
|
|
1797
1816
|
} catch (err) {
|
|
1817
|
+
// Settle any in-flight page apply before acting on the error —
|
|
1818
|
+
// the 410 path below wipes the replica, and an apply landing
|
|
1819
|
+
// after the wipe would resurrect stale rows and advance the
|
|
1820
|
+
// cursor under the recursive re-pull. Failures are already
|
|
1821
|
+
// handled batch-locally; only settlement matters here.
|
|
1822
|
+
if (pendingApply) await pendingApply.catch(() => {});
|
|
1798
1823
|
// Swallow network + transient errors so the poll/reconnect loop
|
|
1799
1824
|
// keeps trying — but on 429 bump the backoff counter so the next
|
|
1800
1825
|
// reconnect waits noticeably longer. Without this, a rate-limited
|
package/src/scenarios.test.ts
CHANGED
|
@@ -555,6 +555,74 @@ describe("sync scenarios", () => {
|
|
|
555
555
|
expect(env.engine.store.get("Note", "n2")).not.toBeNull();
|
|
556
556
|
});
|
|
557
557
|
|
|
558
|
+
// CATCH-UP PIPELINING (pins the pullInner fetch/apply overlap). A
|
|
559
|
+
// multi-page delta catch-up used to serialize fetch page N → apply
|
|
560
|
+
// page N → fetch page N+1, paying network + apply per page in SUM.
|
|
561
|
+
// The pipelined loop derives the next `since` from each RESPONSE's
|
|
562
|
+
// cursor and starts the next fetch while the previous page is still
|
|
563
|
+
// applying, so per-page cost is max(network, apply). This test slows
|
|
564
|
+
// applies down and asserts (a) a later page's fetch arrives at the
|
|
565
|
+
// server WHILE an apply is still in flight, and (b) every page still
|
|
566
|
+
// lands, in order, with nothing skipped.
|
|
567
|
+
test("multi-page delta catch-up overlaps fetch with apply and drains completely", async () => {
|
|
568
|
+
let deltaFetches = 0;
|
|
569
|
+
let applyEnds = 0;
|
|
570
|
+
let overlapped = false;
|
|
571
|
+
env = createTestEnv({
|
|
572
|
+
transport: "poll",
|
|
573
|
+
beforePull: (_auth, since) => {
|
|
574
|
+
if (since > 0) {
|
|
575
|
+
deltaFetches++;
|
|
576
|
+
// The serialized loop finishes apply k-1 BEFORE issuing
|
|
577
|
+
// fetch k, so it always arrives with applyEnds == k-1. A
|
|
578
|
+
// fetch arriving with fewer applies completed is the
|
|
579
|
+
// pipeline overlap.
|
|
580
|
+
if (deltaFetches >= 2 && applyEnds < deltaFetches - 1) {
|
|
581
|
+
overlapped = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
env.signIn({ userId: "u1" });
|
|
587
|
+
env.server.seed("Note", [{ id: "n0", title: "seed" }]);
|
|
588
|
+
await env.start();
|
|
589
|
+
await env.flush();
|
|
590
|
+
|
|
591
|
+
// 30 changes behind, served 10 per page → a 3-page catch-up.
|
|
592
|
+
for (let i = 1; i <= 30; i++) {
|
|
593
|
+
env.server.insert("Note", { id: `n${i}`, title: `t${i}` });
|
|
594
|
+
}
|
|
595
|
+
env.server.deltaPageSize = 10;
|
|
596
|
+
|
|
597
|
+
// Make applies observably slow so the overlap window is real.
|
|
598
|
+
const store = env.engine.store as unknown as {
|
|
599
|
+
applyChangesAsync: (c: unknown[]) => Promise<boolean>;
|
|
600
|
+
};
|
|
601
|
+
const realApply = store.applyChangesAsync.bind(store);
|
|
602
|
+
store.applyChangesAsync = async (changes) => {
|
|
603
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
604
|
+
const out = await realApply(changes);
|
|
605
|
+
applyEnds++;
|
|
606
|
+
return out;
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
await env.engine.pull();
|
|
610
|
+
await env.flush();
|
|
611
|
+
|
|
612
|
+
// Every page landed — first, middle, and last row all present.
|
|
613
|
+
expect(env.engine.store.get("Note", "n1")).not.toBeNull();
|
|
614
|
+
expect(env.engine.store.get("Note", "n15")).not.toBeNull();
|
|
615
|
+
expect(env.engine.store.get("Note", "n30")).not.toBeNull();
|
|
616
|
+
// It really paginated (≥3 delta pages), and at least one later
|
|
617
|
+
// fetch overlapped an in-flight apply. A serialized loop never
|
|
618
|
+
// trips `overlapped` because each fetch waits for the apply.
|
|
619
|
+
const deltaPulls = env.server.pullUrls.filter(
|
|
620
|
+
(u) => !u.includes("since=0"),
|
|
621
|
+
).length;
|
|
622
|
+
expect(deltaPulls).toBeGreaterThanOrEqual(3);
|
|
623
|
+
expect(overlapped).toBe(true);
|
|
624
|
+
});
|
|
625
|
+
|
|
558
626
|
// OFFLINE WRITES (pins the transient/permanent split in pushInner).
|
|
559
627
|
// A push that fails with a NETWORK error (offline — fetch rejects, no
|
|
560
628
|
// HTTP status) must keep the mutation `pending` and the optimistic
|
|
@@ -102,6 +102,11 @@ export class TestServer {
|
|
|
102
102
|
/** Count of snapshot pulls served (since = 0). The egress storm was a
|
|
103
103
|
* runaway count here; the regression test bounds it. */
|
|
104
104
|
snapshotPullCount = 0;
|
|
105
|
+
/** When set, delta pulls (since > 0) page their response to this many
|
|
106
|
+
* events per request with a real per-page cursor + has_more — models
|
|
107
|
+
* the production DELTA_BATCH_LIMIT so catch-up pagination and
|
|
108
|
+
* fetch/apply pipelining can be exercised. */
|
|
109
|
+
deltaPageSize: number | null = null;
|
|
105
110
|
/** Count of /api/sync/push requests received. Lets a test assert the
|
|
106
111
|
* engine actually shipped a batch (e.g. hydrated offline writes that
|
|
107
112
|
* must drain once leader-elected), independent of the no-op push
|
|
@@ -278,6 +278,23 @@ async function handle(
|
|
|
278
278
|
// terminating snapshot (no snapshot_after → client exits the loop).
|
|
279
279
|
}
|
|
280
280
|
const resp = await server.pull(token, since);
|
|
281
|
+
// Delta paging sim: slice to `deltaPageSize` events with a real
|
|
282
|
+
// per-page cursor + has_more, like the production DELTA_BATCH_LIMIT.
|
|
283
|
+
if (
|
|
284
|
+
since > 0 &&
|
|
285
|
+
server.deltaPageSize != null &&
|
|
286
|
+
resp.changes.length > server.deltaPageSize
|
|
287
|
+
) {
|
|
288
|
+
const page = resp.changes.slice(0, server.deltaPageSize);
|
|
289
|
+
return {
|
|
290
|
+
status: 200,
|
|
291
|
+
body: {
|
|
292
|
+
changes: page,
|
|
293
|
+
cursor: { last_seq: page[page.length - 1]!.seq },
|
|
294
|
+
has_more: true,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
281
298
|
// One-shot has_more on a delta pull → drives the tail-pull recursion.
|
|
282
299
|
if (since > 0 && server.consumeNextPullHasMore()) {
|
|
283
300
|
return { status: 200, body: { ...resp, has_more: true } };
|