@pylonsync/sync 0.3.275 → 0.3.276
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 +51 -4
- package/src/multi-tab-orchestrator.test.ts +21 -0
- package/src/multi-tab-orchestrator.ts +11 -0
- package/src/reconcile.test.ts +46 -0
- package/src/round6-codex.test.ts +85 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1033,6 +1033,20 @@ export class SyncEngine {
|
|
|
1033
1033
|
this.broadcastToTabs({ type: "entity-observe", entity });
|
|
1034
1034
|
}
|
|
1035
1035
|
},
|
|
1036
|
+
onReplayForwardedMutations: () => {
|
|
1037
|
+
// Follower path: re-forward our pending batch to the new leader.
|
|
1038
|
+
// If we'd forwarded an op to the previous (now-dead) leader, it
|
|
1039
|
+
// died before acking, so the op is still pending here as an
|
|
1040
|
+
// optimistic ghost with nothing pushing it. The new leader only
|
|
1041
|
+
// drains its OWN queue on promotion, so without this re-forward the
|
|
1042
|
+
// op is silently lost until our next local write. The leader's
|
|
1043
|
+
// `onMutationsForwarded` re-adds by op_id (idempotent) and pushes.
|
|
1044
|
+
if (this.isMultiTabLeader) return;
|
|
1045
|
+
const pending = this.mutations.pending();
|
|
1046
|
+
if (pending.length > 0) {
|
|
1047
|
+
this.broadcastToTabs({ type: "mutations", ops: pending });
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1036
1050
|
};
|
|
1037
1051
|
}
|
|
1038
1052
|
|
|
@@ -1672,6 +1686,12 @@ export class SyncEngine {
|
|
|
1672
1686
|
* reconcile sweep them too. See `observeEntity`. */
|
|
1673
1687
|
private observedEntities = new Set<string>();
|
|
1674
1688
|
|
|
1689
|
+
/** Per-entity count of CONSECUTIVE 403s seen during reconcile, reset on
|
|
1690
|
+
* any successful fetch. A single 403 can be transient (a bearer caught
|
|
1691
|
+
* mid-refresh, a momentary policy blip) and must NOT wipe the local cache;
|
|
1692
|
+
* we only drop an entity's rows after two in a row. See `reconcile`. */
|
|
1693
|
+
private reconcile403Streak = new Map<string, number>();
|
|
1694
|
+
|
|
1675
1695
|
/**
|
|
1676
1696
|
* Reconcile the local replica against server truth.
|
|
1677
1697
|
*
|
|
@@ -1819,14 +1839,41 @@ export class SyncEngine {
|
|
|
1819
1839
|
// Network errors are expected (offline, transient 5xx). Skip
|
|
1820
1840
|
// this entity; the next reconcile trigger will retry.
|
|
1821
1841
|
const status = (err as { status?: number })?.status;
|
|
1822
|
-
if (status ===
|
|
1823
|
-
// Entity
|
|
1824
|
-
|
|
1825
|
-
|
|
1842
|
+
if (status === 404) {
|
|
1843
|
+
// Entity removed from the manifest — definitive. Drop its rows.
|
|
1844
|
+
this.reconcile403Streak.delete(entity);
|
|
1845
|
+
await this.dropEntity(entity, tombstoneSeq);
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
if (status === 403) {
|
|
1849
|
+
// A 403 can be TRANSIENT — a bearer caught mid-refresh, a tenant
|
|
1850
|
+
// flip that raced the fetch, or a momentary server-side policy
|
|
1851
|
+
// blip. Nuking every local row on the first one makes the data
|
|
1852
|
+
// flash away and reappear next reconcile (the "rows vanish on
|
|
1853
|
+
// load" bug, on the error path this time). Two gates before we
|
|
1854
|
+
// drop:
|
|
1855
|
+
// 1. If the session flipped during the fetch, the 403 reflects
|
|
1856
|
+
// the OLD context — never drop; the next reconcile under the
|
|
1857
|
+
// new session decides (mirrors the success-path guard below).
|
|
1858
|
+
if (this.session.signature() !== sessionBeforeFetch) {
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
// 2. Otherwise require TWO consecutive 403s for this entity, so
|
|
1862
|
+
// a one-off blip can't wipe the cache. A successful fetch
|
|
1863
|
+
// (below) resets the streak.
|
|
1864
|
+
const streak = (this.reconcile403Streak.get(entity) ?? 0) + 1;
|
|
1865
|
+
if (streak < 2) {
|
|
1866
|
+
this.reconcile403Streak.set(entity, streak);
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
this.reconcile403Streak.delete(entity);
|
|
1826
1870
|
await this.dropEntity(entity, tombstoneSeq);
|
|
1827
1871
|
}
|
|
1828
1872
|
return;
|
|
1829
1873
|
}
|
|
1874
|
+
// Successful fetch — the entity is readable, so any prior 403 streak
|
|
1875
|
+
// is broken (even if a drift guard below makes us skip the apply).
|
|
1876
|
+
this.reconcile403Streak.delete(entity);
|
|
1830
1877
|
if (this.cursor.last_seq !== cursorBeforeFetch) {
|
|
1831
1878
|
// Cursor moved during fetch — at least one WS event for this
|
|
1832
1879
|
// (or another) entity landed and might have a fresher value
|
|
@@ -37,6 +37,7 @@ function makeRig(opts: { isLeader?: boolean } = {}) {
|
|
|
37
37
|
onMutationsFailed: record("mutationsFailed"),
|
|
38
38
|
onBinaryReceived: record("binary"),
|
|
39
39
|
onPeerLeft: record("peerLeft"),
|
|
40
|
+
onReplayForwardedMutations: record("replayForwardedMutations"),
|
|
40
41
|
},
|
|
41
42
|
);
|
|
42
43
|
return { orch, events, serverSubs, subs };
|
|
@@ -89,6 +90,26 @@ describe("MultiTabOrchestrator dispatch", () => {
|
|
|
89
90
|
expect(follower.events.length).toBe(0);
|
|
90
91
|
});
|
|
91
92
|
|
|
93
|
+
test("request-sub-replay re-forwards a follower's pending mutations (#341)", () => {
|
|
94
|
+
// A new leader broadcasts request-sub-replay after a handoff. Followers
|
|
95
|
+
// must re-forward their pending mutations — an op forwarded to the
|
|
96
|
+
// previous (now-dead) leader is otherwise stranded, since the new leader
|
|
97
|
+
// only drains its OWN queue on promotion. makeRig's orchestrator never
|
|
98
|
+
// ran init(), so _isLeader is false → it acts as a follower here.
|
|
99
|
+
const { orch, events } = makeRig();
|
|
100
|
+
orch.handleMessage({ type: "request-sub-replay" }, "new-leader");
|
|
101
|
+
expect(events.map((e) => e.kind)).toContain("replayForwardedMutations");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("request-sub-replay does NOT re-forward on the leader (#341)", () => {
|
|
105
|
+
const { orch, events } = makeRig();
|
|
106
|
+
// Promote this rig to leader; a leader receiving the replay request (its
|
|
107
|
+
// own echo, or a stale broadcast) owns the network and must not act.
|
|
108
|
+
(orch as unknown as { _isLeader: boolean })._isLeader = true;
|
|
109
|
+
orch.handleMessage({ type: "request-sub-replay" }, "x");
|
|
110
|
+
expect(events.map((e) => e.kind)).not.toContain("replayForwardedMutations");
|
|
111
|
+
});
|
|
112
|
+
|
|
92
113
|
test("mutations-acked + mutations-failed both fire their hooks", () => {
|
|
93
114
|
const { orch, events } = makeRig();
|
|
94
115
|
orch.handleMessage({ type: "mutations-acked", opIds: ["a", "b"] }, "x");
|
|
@@ -125,6 +125,12 @@ export interface MultiTabOrchestratorHooks {
|
|
|
125
125
|
/** New leader → followers: re-declare your observed entities so the
|
|
126
126
|
* new leader's reconcile sweep covers them. */
|
|
127
127
|
onReplayObservedEntities?(): void;
|
|
128
|
+
/** New leader → followers: re-forward your pending mutation batch. A
|
|
129
|
+
* mutation a follower forwarded to a leader that died before acking is
|
|
130
|
+
* otherwise stranded — the new leader only drains its OWN queue on
|
|
131
|
+
* promotion, and the other replay hooks cover subs/rooms/entities but
|
|
132
|
+
* not mutations. op_id makes the re-forward idempotent. */
|
|
133
|
+
onReplayForwardedMutations?(): void;
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
export interface MultiTabOrchestratorConfig {
|
|
@@ -404,6 +410,11 @@ export class MultiTabOrchestrator {
|
|
|
404
410
|
// ...and re-declare observed entities so the new leader's
|
|
405
411
|
// reconcile sweep covers them.
|
|
406
412
|
this.hooks.onReplayObservedEntities?.();
|
|
413
|
+
// ...and re-forward any pending mutations. A mutation forwarded to
|
|
414
|
+
// a now-dead leader (which never acked) is otherwise stranded as an
|
|
415
|
+
// optimistic ghost until this follower's next write — the new
|
|
416
|
+
// leader only drained its own queue on promotion.
|
|
417
|
+
this.hooks.onReplayForwardedMutations?.();
|
|
407
418
|
break;
|
|
408
419
|
}
|
|
409
420
|
case "entity-observe": {
|
package/src/reconcile.test.ts
CHANGED
|
@@ -247,6 +247,52 @@ describe("SyncEngine.reconcile", () => {
|
|
|
247
247
|
await engine.reconcile(["DeletedEntity"]);
|
|
248
248
|
expect(engine.store.list("DeletedEntity").length).toBe(0);
|
|
249
249
|
});
|
|
250
|
+
|
|
251
|
+
test("transient 403 keeps rows; two consecutive 403s drop them (#343)", async () => {
|
|
252
|
+
// A single 403 can be a bearer caught mid-refresh or a momentary policy
|
|
253
|
+
// blip; nuking the cache on the first one made rows flash away and return
|
|
254
|
+
// next reconcile. The first 403 must keep the rows.
|
|
255
|
+
const status = 403;
|
|
256
|
+
restore = installFetch(async () => ({ status, body: {} }));
|
|
257
|
+
const engine = makeEngine();
|
|
258
|
+
seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
|
|
259
|
+
|
|
260
|
+
await engine.reconcile(["Recording"]);
|
|
261
|
+
expect(engine.store.list("Recording").length).toBe(1);
|
|
262
|
+
|
|
263
|
+
// A second consecutive 403 confirms access is really gone — now drop.
|
|
264
|
+
await engine.reconcile(["Recording"]);
|
|
265
|
+
expect(engine.store.list("Recording").length).toBe(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("a successful fetch resets the 403 streak (#343)", async () => {
|
|
269
|
+
let status = 403;
|
|
270
|
+
restore = installFetch(async () =>
|
|
271
|
+
status === 200
|
|
272
|
+
? {
|
|
273
|
+
status: 200,
|
|
274
|
+
body: {
|
|
275
|
+
data: [{ id: "r1", title: "alive" }],
|
|
276
|
+
next_cursor: null,
|
|
277
|
+
has_more: false,
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
: { status: 403, body: {} },
|
|
281
|
+
);
|
|
282
|
+
const engine = makeEngine();
|
|
283
|
+
seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
|
|
284
|
+
|
|
285
|
+
await engine.reconcile(["Recording"]); // 403 #1 — kept
|
|
286
|
+
expect(engine.store.list("Recording").length).toBe(1);
|
|
287
|
+
|
|
288
|
+
status = 200;
|
|
289
|
+
await engine.reconcile(["Recording"]); // success — streak resets
|
|
290
|
+
expect(engine.store.list("Recording").length).toBe(1);
|
|
291
|
+
|
|
292
|
+
status = 403;
|
|
293
|
+
await engine.reconcile(["Recording"]); // a fresh lone 403 must NOT drop
|
|
294
|
+
expect(engine.store.list("Recording").length).toBe(1);
|
|
295
|
+
});
|
|
250
296
|
});
|
|
251
297
|
|
|
252
298
|
describe("LocalStore.reconcileRemove", () => {
|
package/src/round6-codex.test.ts
CHANGED
|
@@ -483,3 +483,88 @@ describe("sync hardening: multi-tab follower coverage", () => {
|
|
|
483
483
|
expect(row?.title).toBe("keep");
|
|
484
484
|
});
|
|
485
485
|
});
|
|
486
|
+
|
|
487
|
+
describe("#341: leader-handoff re-forwards a follower's pending mutations", () => {
|
|
488
|
+
let env: TestEnv | null = null;
|
|
489
|
+
afterEach(async () => {
|
|
490
|
+
if (env) await env.dispose();
|
|
491
|
+
env = null;
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// The engine-side half of the fix: a follower's onReplayForwardedMutations
|
|
495
|
+
// hook (fired when a new leader broadcasts request-sub-replay) re-forwards
|
|
496
|
+
// its pending batch. Pre-fix the hook didn't exist, so an op forwarded to a
|
|
497
|
+
// now-dead leader was stranded until the follower's next local write.
|
|
498
|
+
test("a follower re-forwards its pending batch on replay", async () => {
|
|
499
|
+
env = createTestEnv();
|
|
500
|
+
env.signIn({ userId: "u1" });
|
|
501
|
+
await env.start();
|
|
502
|
+
|
|
503
|
+
const engine = env.engine as unknown as {
|
|
504
|
+
isMultiTabLeader: boolean;
|
|
505
|
+
multiTabHooks(): { onReplayForwardedMutations?: () => void };
|
|
506
|
+
broadcastToTabs(payload: unknown): void;
|
|
507
|
+
mutations: { add(change: unknown): string };
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Become a follower holding an op we'd forwarded to a leader that died
|
|
511
|
+
// before acking — it's still pending here with nothing pushing it.
|
|
512
|
+
engine.isMultiTabLeader = false;
|
|
513
|
+
engine.mutations.add({
|
|
514
|
+
op_id: "op-stranded",
|
|
515
|
+
entity: "Todo",
|
|
516
|
+
row_id: "x",
|
|
517
|
+
kind: "insert",
|
|
518
|
+
data: { id: "x", text: "stranded" },
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const forwarded: { ops: { change: { op_id: string } }[] }[] = [];
|
|
522
|
+
const orig = engine.broadcastToTabs.bind(engine);
|
|
523
|
+
engine.broadcastToTabs = (p: unknown) => {
|
|
524
|
+
if ((p as { type?: string }).type === "mutations") {
|
|
525
|
+
forwarded.push(p as { ops: { change: { op_id: string } }[] });
|
|
526
|
+
}
|
|
527
|
+
orig(p);
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// New leader → followers: re-forward your subs/rooms/entities AND your
|
|
531
|
+
// pending mutations. This is the hook the request-sub-replay handler now
|
|
532
|
+
// invokes.
|
|
533
|
+
engine.multiTabHooks().onReplayForwardedMutations?.();
|
|
534
|
+
|
|
535
|
+
expect(forwarded.length).toBe(1);
|
|
536
|
+
expect(forwarded[0].ops.map((o) => o.change.op_id)).toContain("op-stranded");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("the leader does NOT re-forward on replay (it owns the network)", async () => {
|
|
540
|
+
env = createTestEnv(); // multiTab:false → this engine is the leader
|
|
541
|
+
env.signIn({ userId: "u1" });
|
|
542
|
+
await env.start();
|
|
543
|
+
|
|
544
|
+
const engine = env.engine as unknown as {
|
|
545
|
+
isMultiTabLeader: boolean;
|
|
546
|
+
multiTabHooks(): { onReplayForwardedMutations?: () => void };
|
|
547
|
+
broadcastToTabs(payload: unknown): void;
|
|
548
|
+
mutations: { add(change: unknown): string };
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
expect(engine.isMultiTabLeader).toBe(true);
|
|
552
|
+
engine.mutations.add({
|
|
553
|
+
op_id: "op-leader",
|
|
554
|
+
entity: "Todo",
|
|
555
|
+
row_id: "y",
|
|
556
|
+
kind: "insert",
|
|
557
|
+
data: { id: "y", text: "mine" },
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const forwarded: unknown[] = [];
|
|
561
|
+
const orig = engine.broadcastToTabs.bind(engine);
|
|
562
|
+
engine.broadcastToTabs = (p: unknown) => {
|
|
563
|
+
if ((p as { type?: string }).type === "mutations") forwarded.push(p);
|
|
564
|
+
orig(p);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
engine.multiTabHooks().onReplayForwardedMutations?.();
|
|
568
|
+
expect(forwarded.length).toBe(0);
|
|
569
|
+
});
|
|
570
|
+
});
|