@pylonsync/sync 0.22.41 → 0.22.43
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/index.d.ts +29 -0
- package/package.json +1 -1
- package/src/index.ts +99 -10
- package/src/reconcile.test.ts +145 -0
package/dist/index.d.ts
CHANGED
|
@@ -733,6 +733,35 @@ export declare class SyncEngine {
|
|
|
733
733
|
*/
|
|
734
734
|
observeEntity(entity: string): void;
|
|
735
735
|
reconcile(entities?: string[]): Promise<void>;
|
|
736
|
+
/** Entities a server hint (`sync-reconcile`) asked to reconcile that
|
|
737
|
+
* have not reconciled yet. */
|
|
738
|
+
private hintPending;
|
|
739
|
+
private hintTimer;
|
|
740
|
+
private hintRetries;
|
|
741
|
+
private lastHintReconcileAt;
|
|
742
|
+
/** Hints that arrive this close together share one reconcile. */
|
|
743
|
+
private static readonly HINT_DEBOUNCE_MS;
|
|
744
|
+
/** At most one hinted reconcile per this interval. */
|
|
745
|
+
private static readonly HINT_MIN_INTERVAL_MS;
|
|
746
|
+
/** Backoff for entities whose fetch raced a newer change: doubling from
|
|
747
|
+
* the base, capped. */
|
|
748
|
+
private static readonly HINT_RETRY_BASE_MS;
|
|
749
|
+
private static readonly HINT_RETRY_MAX_MS;
|
|
750
|
+
/**
|
|
751
|
+
* The server said a change elsewhere (a share, an unshare, a folder
|
|
752
|
+
* membership) changed which rows of `entities` this user may read
|
|
753
|
+
* (`sync-reconcile`). No other event will tell this replica, so the
|
|
754
|
+
* entities stay pending until a reconcile of them completes: hints are
|
|
755
|
+
* merged, run at most once a second, and an entity whose fetch raced a
|
|
756
|
+
* newer change is retried with backoff until it lands or the engine
|
|
757
|
+
* stops.
|
|
758
|
+
*/
|
|
759
|
+
private queueHintReconcile;
|
|
760
|
+
private scheduleHintReconcile;
|
|
761
|
+
private flushHintReconcile;
|
|
762
|
+
/** `reconcile`, resolving with the entities skipped because the cursor
|
|
763
|
+
* moved during their fetch. */
|
|
764
|
+
private reconcileReportingSkips;
|
|
736
765
|
/** Scoped reconcile batch that has not started running yet. */
|
|
737
766
|
private scopedReconcileBatch;
|
|
738
767
|
private scopedReconcileSeq;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1651,6 +1651,12 @@ export class SyncEngine {
|
|
|
1651
1651
|
/** Stop the sync engine. */
|
|
1652
1652
|
stop(): void {
|
|
1653
1653
|
this.running = false;
|
|
1654
|
+
if (this.hintTimer !== null) {
|
|
1655
|
+
clearTimeout(this.hintTimer);
|
|
1656
|
+
this.hintTimer = null;
|
|
1657
|
+
}
|
|
1658
|
+
this.hintPending.clear();
|
|
1659
|
+
this.hintRetries = 0;
|
|
1654
1660
|
if (this.identityRetryTimer !== null) {
|
|
1655
1661
|
clearTimeout(this.identityRetryTimer);
|
|
1656
1662
|
this.identityRetryTimer = null;
|
|
@@ -2303,10 +2309,75 @@ export class SyncEngine {
|
|
|
2303
2309
|
}
|
|
2304
2310
|
|
|
2305
2311
|
async reconcile(entities?: string[]): Promise<void> {
|
|
2312
|
+
await this.reconcileReportingSkips(entities);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/** Entities a server hint (`sync-reconcile`) asked to reconcile that
|
|
2316
|
+
* have not reconciled yet. */
|
|
2317
|
+
private hintPending = new Set<string>();
|
|
2318
|
+
private hintTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2319
|
+
private hintRetries = 0;
|
|
2320
|
+
private lastHintReconcileAt = 0;
|
|
2321
|
+
/** Hints that arrive this close together share one reconcile. */
|
|
2322
|
+
private static readonly HINT_DEBOUNCE_MS = 100;
|
|
2323
|
+
/** At most one hinted reconcile per this interval. */
|
|
2324
|
+
private static readonly HINT_MIN_INTERVAL_MS = 1_000;
|
|
2325
|
+
/** Backoff for entities whose fetch raced a newer change: doubling from
|
|
2326
|
+
* the base, capped. */
|
|
2327
|
+
private static readonly HINT_RETRY_BASE_MS = 250;
|
|
2328
|
+
private static readonly HINT_RETRY_MAX_MS = 10_000;
|
|
2329
|
+
|
|
2330
|
+
/**
|
|
2331
|
+
* The server said a change elsewhere (a share, an unshare, a folder
|
|
2332
|
+
* membership) changed which rows of `entities` this user may read
|
|
2333
|
+
* (`sync-reconcile`). No other event will tell this replica, so the
|
|
2334
|
+
* entities stay pending until a reconcile of them completes: hints are
|
|
2335
|
+
* merged, run at most once a second, and an entity whose fetch raced a
|
|
2336
|
+
* newer change is retried with backoff until it lands or the engine
|
|
2337
|
+
* stops.
|
|
2338
|
+
*/
|
|
2339
|
+
private queueHintReconcile(entities: string[]): void {
|
|
2340
|
+
for (const e of entities) this.hintPending.add(e);
|
|
2341
|
+
this.scheduleHintReconcile(SyncEngine.HINT_DEBOUNCE_MS);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
private scheduleHintReconcile(delayMs: number): void {
|
|
2345
|
+
if (this.hintTimer !== null || !this.running) return;
|
|
2346
|
+
const wait = Math.max(delayMs, this.lastHintReconcileAt + SyncEngine.HINT_MIN_INTERVAL_MS - Date.now());
|
|
2347
|
+
this.hintTimer = setTimeout(() => {
|
|
2348
|
+
this.hintTimer = null;
|
|
2349
|
+
void this.flushHintReconcile();
|
|
2350
|
+
}, wait);
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
private async flushHintReconcile(): Promise<void> {
|
|
2354
|
+
if (!this.running || this.hintPending.size === 0) return;
|
|
2355
|
+
const entities = [...this.hintPending];
|
|
2356
|
+
this.hintPending.clear();
|
|
2357
|
+
this.lastHintReconcileAt = Date.now();
|
|
2358
|
+
let skipped: string[];
|
|
2359
|
+
try {
|
|
2360
|
+
skipped = await this.reconcileReportingSkips(entities);
|
|
2361
|
+
} catch {
|
|
2362
|
+
skipped = entities;
|
|
2363
|
+
}
|
|
2364
|
+
for (const e of skipped) this.hintPending.add(e);
|
|
2365
|
+
this.hintRetries = skipped.length > 0 ? this.hintRetries + 1 : 0;
|
|
2366
|
+
if (this.hintPending.size === 0) return;
|
|
2367
|
+
const delay =
|
|
2368
|
+
skipped.length > 0
|
|
2369
|
+
? Math.min(SyncEngine.HINT_RETRY_BASE_MS * 2 ** (this.hintRetries - 1), SyncEngine.HINT_RETRY_MAX_MS)
|
|
2370
|
+
: SyncEngine.HINT_DEBOUNCE_MS;
|
|
2371
|
+
this.scheduleHintReconcile(delay);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
/** `reconcile`, resolving with the entities skipped because the cursor
|
|
2375
|
+
* moved during their fetch. */
|
|
2376
|
+
private async reconcileReportingSkips(entities?: string[]): Promise<string[]> {
|
|
2306
2377
|
const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
|
|
2307
2378
|
const now = Date.now();
|
|
2308
2379
|
if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
|
|
2309
|
-
return;
|
|
2380
|
+
return [];
|
|
2310
2381
|
}
|
|
2311
2382
|
// Reconcile waits behind any in-flight pull / refresh / push so it
|
|
2312
2383
|
// can't apply rows captured under a stale session.
|
|
@@ -2315,7 +2386,7 @@ export class SyncEngine {
|
|
|
2315
2386
|
// the same window share one fetch per entity.
|
|
2316
2387
|
return this.opQueue.enqueue("reconcile", async () => {
|
|
2317
2388
|
try {
|
|
2318
|
-
await this.reconcileInner();
|
|
2389
|
+
return await this.reconcileInner();
|
|
2319
2390
|
} finally {
|
|
2320
2391
|
this.lastReconcileAt = Date.now();
|
|
2321
2392
|
}
|
|
@@ -2330,9 +2401,9 @@ export class SyncEngine {
|
|
|
2330
2401
|
for (const e of entities) open.entities.add(e);
|
|
2331
2402
|
return open.promise;
|
|
2332
2403
|
}
|
|
2333
|
-
const batch: { entities: Set<string>; promise: Promise<
|
|
2404
|
+
const batch: { entities: Set<string>; promise: Promise<string[]> } = {
|
|
2334
2405
|
entities: new Set(entities),
|
|
2335
|
-
promise: Promise.resolve(),
|
|
2406
|
+
promise: Promise.resolve([]),
|
|
2336
2407
|
};
|
|
2337
2408
|
this.scopedReconcileBatch = batch;
|
|
2338
2409
|
this.scopedReconcileSeq += 1;
|
|
@@ -2341,7 +2412,7 @@ export class SyncEngine {
|
|
|
2341
2412
|
async () => {
|
|
2342
2413
|
if (this.scopedReconcileBatch === batch) this.scopedReconcileBatch = null;
|
|
2343
2414
|
try {
|
|
2344
|
-
await this.reconcileInner([...batch.entities]);
|
|
2415
|
+
return await this.reconcileInner([...batch.entities]);
|
|
2345
2416
|
} finally {
|
|
2346
2417
|
this.lastReconcileAt = Date.now();
|
|
2347
2418
|
}
|
|
@@ -2353,14 +2424,14 @@ export class SyncEngine {
|
|
|
2353
2424
|
/** Scoped reconcile batch that has not started running yet. */
|
|
2354
2425
|
private scopedReconcileBatch: {
|
|
2355
2426
|
entities: Set<string>;
|
|
2356
|
-
promise: Promise<
|
|
2427
|
+
promise: Promise<string[]>;
|
|
2357
2428
|
} | null = null;
|
|
2358
2429
|
private scopedReconcileSeq = 0;
|
|
2359
2430
|
|
|
2360
|
-
private async reconcileInner(entities?: string[]): Promise<
|
|
2431
|
+
private async reconcileInner(entities?: string[]): Promise<string[]> {
|
|
2361
2432
|
// Same reasoning as pullInner: the leader reconciles, broadcasts
|
|
2362
2433
|
// results, and follower replicas converge via the channel.
|
|
2363
|
-
if (!this.isMultiTabLeader) return;
|
|
2434
|
+
if (!this.isMultiTabLeader) return [];
|
|
2364
2435
|
// Sweep entities with local rows PLUS entities the app has observed
|
|
2365
2436
|
// via useQuery (even when empty locally). Without the observed set,
|
|
2366
2437
|
// a server row in a never-cached entity is never reconciled and
|
|
@@ -2368,7 +2439,8 @@ export class SyncEngine {
|
|
|
2368
2439
|
const names =
|
|
2369
2440
|
entities ??
|
|
2370
2441
|
[...new Set([...this.store.entityNames(), ...this.observedEntities])];
|
|
2371
|
-
if (names.length === 0) return;
|
|
2442
|
+
if (names.length === 0) return [];
|
|
2443
|
+
const skipped: string[] = [];
|
|
2372
2444
|
// Tombstone seq for any local row the server doesn't return. Using
|
|
2373
2445
|
// the current cursor means future inserts (which have higher seqs)
|
|
2374
2446
|
// bypass the tombstone — re-creation server-side still propagates.
|
|
@@ -2444,11 +2516,16 @@ export class SyncEngine {
|
|
|
2444
2516
|
const streak = (this.reconcile403Streak.get(entity) ?? 0) + 1;
|
|
2445
2517
|
if (streak < 2) {
|
|
2446
2518
|
this.reconcile403Streak.set(entity, streak);
|
|
2519
|
+
skipped.push(entity);
|
|
2447
2520
|
return;
|
|
2448
2521
|
}
|
|
2449
2522
|
this.reconcile403Streak.delete(entity);
|
|
2450
2523
|
await this.dropEntity(entity, tombstoneSeq);
|
|
2524
|
+
return;
|
|
2451
2525
|
}
|
|
2526
|
+
// Not reconciled (offline, a 5xx): reported, so a server hint
|
|
2527
|
+
// stays pending and retries.
|
|
2528
|
+
skipped.push(entity);
|
|
2452
2529
|
return;
|
|
2453
2530
|
}
|
|
2454
2531
|
// Successful fetch — the entity is readable, so any prior 403 streak
|
|
@@ -2460,7 +2537,9 @@ export class SyncEngine {
|
|
|
2460
2537
|
// for a row our snapshot just captured. Bail out for this
|
|
2461
2538
|
// entity; reconcile() is triggered again on visibility-change
|
|
2462
2539
|
// and reconnect, and the WS event already carried the latest
|
|
2463
|
-
// state for the affected row.
|
|
2540
|
+
// state for the affected row. Reported, so a server hint can
|
|
2541
|
+
// retry (flushHintReconcile).
|
|
2542
|
+
skipped.push(entity);
|
|
2464
2543
|
return;
|
|
2465
2544
|
}
|
|
2466
2545
|
if (this.session.signature() !== sessionBeforeFetch) {
|
|
@@ -2479,6 +2558,7 @@ export class SyncEngine {
|
|
|
2479
2558
|
);
|
|
2480
2559
|
}),
|
|
2481
2560
|
);
|
|
2561
|
+
return skipped;
|
|
2482
2562
|
}
|
|
2483
2563
|
|
|
2484
2564
|
/** Fetch every row for an entity. Uses cursor pagination so big tables
|
|
@@ -4105,6 +4185,15 @@ export class SyncEngine {
|
|
|
4105
4185
|
// fresh /api/auth/me read which updates the cached session AND,
|
|
4106
4186
|
// on tenant flip, resets the replica so stale rows from the
|
|
4107
4187
|
// previous tenant disappear.
|
|
4188
|
+
// A change elsewhere (a share, an unshare, a folder membership)
|
|
4189
|
+
// changed which rows of these entities this user may read, without a
|
|
4190
|
+
// change event for the rows themselves. Reconcile them.
|
|
4191
|
+
if (msg.type === "sync-reconcile" && Array.isArray(msg.entities)) {
|
|
4192
|
+
const entities = msg.entities.filter((e): e is string => typeof e === "string");
|
|
4193
|
+
if (entities.length > 0) this.queueHintReconcile(entities);
|
|
4194
|
+
return;
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4108
4197
|
if (msg.type === "session-changed") {
|
|
4109
4198
|
void this.refreshResolvedSession();
|
|
4110
4199
|
return;
|
package/src/reconcile.test.ts
CHANGED
|
@@ -668,3 +668,148 @@ describe("SyncEngine cookie auth", () => {
|
|
|
668
668
|
expect(notifications).toBe(1);
|
|
669
669
|
});
|
|
670
670
|
});
|
|
671
|
+
|
|
672
|
+
// The server sends `sync-reconcile` when a row of another entity changed
|
|
673
|
+
// which rows of these entities the user may read (a share, an unshare):
|
|
674
|
+
// no change event of the rows themselves will come.
|
|
675
|
+
describe("sync-reconcile hint", () => {
|
|
676
|
+
let restore: (() => void) | null = null;
|
|
677
|
+
|
|
678
|
+
afterEach(() => {
|
|
679
|
+
restore?.();
|
|
680
|
+
restore = null;
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
type Internals = {
|
|
684
|
+
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
685
|
+
running: boolean;
|
|
686
|
+
cursor: { last_seq: number };
|
|
687
|
+
hintPending: Set<string>;
|
|
688
|
+
};
|
|
689
|
+
const inner = (engine: SyncEngine) => engine as unknown as Internals;
|
|
690
|
+
const deliver = (engine: SyncEngine, msg: Record<string, unknown>) => inner(engine).dispatchInboundMessage(msg);
|
|
691
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
692
|
+
|
|
693
|
+
function documentServer(onFetch: (n: number) => void = () => {}) {
|
|
694
|
+
let fetches = 0;
|
|
695
|
+
restore = installFetch(async (url) => {
|
|
696
|
+
if (url.includes("/api/entities/Document/cursor")) {
|
|
697
|
+
fetches += 1;
|
|
698
|
+
onFetch(fetches);
|
|
699
|
+
return {
|
|
700
|
+
status: 200,
|
|
701
|
+
body: { data: [{ id: "shared", title: "Now shared" }], next_cursor: null, has_more: false },
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
return { status: 404, body: {} };
|
|
705
|
+
});
|
|
706
|
+
return () => fetches;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
test("reconciles the named entities: the shared row arrives, the unshared one leaves", async () => {
|
|
710
|
+
const fetches = documentServer();
|
|
711
|
+
const engine = makeEngine();
|
|
712
|
+
inner(engine).running = true;
|
|
713
|
+
seedStore(engine, "Document", [{ id: "unshared", title: "No longer shared" }]);
|
|
714
|
+
|
|
715
|
+
deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
|
|
716
|
+
await sleep(250);
|
|
717
|
+
|
|
718
|
+
expect(fetches()).toBe(1);
|
|
719
|
+
expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
|
|
720
|
+
engine.stop();
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("hints that arrive together share one fetch", async () => {
|
|
724
|
+
const fetches = documentServer();
|
|
725
|
+
const engine = makeEngine();
|
|
726
|
+
inner(engine).running = true;
|
|
727
|
+
for (let i = 0; i < 5; i++) deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
|
|
728
|
+
await sleep(250);
|
|
729
|
+
expect(fetches()).toBe(1);
|
|
730
|
+
engine.stop();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("keeps retrying while newer changes race the fetch, then lands", async () => {
|
|
734
|
+
const engine = makeEngine();
|
|
735
|
+
// The first two fetches race a change event: the cursor moves.
|
|
736
|
+
const fetches = documentServer((n) => {
|
|
737
|
+
if (n <= 2) inner(engine).cursor.last_seq += 1;
|
|
738
|
+
});
|
|
739
|
+
inner(engine).running = true;
|
|
740
|
+
|
|
741
|
+
deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
|
|
742
|
+
await sleep(250);
|
|
743
|
+
expect(fetches()).toBe(1);
|
|
744
|
+
expect(engine.store.list("Document")).toEqual([]);
|
|
745
|
+
expect([...inner(engine).hintPending]).toEqual(["Document"]);
|
|
746
|
+
|
|
747
|
+
// Retries run at most once a second.
|
|
748
|
+
await sleep(2300);
|
|
749
|
+
expect(fetches()).toBe(3);
|
|
750
|
+
expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
|
|
751
|
+
expect(inner(engine).hintPending.size).toBe(0);
|
|
752
|
+
engine.stop();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("stop drops pending hints", async () => {
|
|
756
|
+
const fetches = documentServer();
|
|
757
|
+
const engine = makeEngine();
|
|
758
|
+
inner(engine).running = true;
|
|
759
|
+
deliver(engine, { type: "sync-reconcile", entities: ["Document"] });
|
|
760
|
+
engine.stop();
|
|
761
|
+
await sleep(250);
|
|
762
|
+
expect(fetches()).toBe(0);
|
|
763
|
+
expect(inner(engine).hintPending.size).toBe(0);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("ignores a hint without entity names", async () => {
|
|
767
|
+
const fetches = documentServer();
|
|
768
|
+
const engine = makeEngine();
|
|
769
|
+
inner(engine).running = true;
|
|
770
|
+
deliver(engine, { type: "sync-reconcile", entities: [42, null] });
|
|
771
|
+
deliver(engine, { type: "sync-reconcile" });
|
|
772
|
+
await sleep(250);
|
|
773
|
+
expect(fetches()).toBe(0);
|
|
774
|
+
engine.stop();
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe("sync-reconcile hint after a failed fetch", () => {
|
|
779
|
+
let restore: (() => void) | null = null;
|
|
780
|
+
afterEach(() => {
|
|
781
|
+
restore?.();
|
|
782
|
+
restore = null;
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("a 5xx keeps the hint pending and the retry lands", async () => {
|
|
786
|
+
let fetches = 0;
|
|
787
|
+
restore = installFetch(async (url) => {
|
|
788
|
+
if (url.includes("/api/entities/Document/cursor")) {
|
|
789
|
+
fetches += 1;
|
|
790
|
+
if (fetches === 1) return { status: 503, body: {} };
|
|
791
|
+
return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
|
|
792
|
+
}
|
|
793
|
+
return { status: 404, body: {} };
|
|
794
|
+
});
|
|
795
|
+
const engine = makeEngine();
|
|
796
|
+
const inner = engine as unknown as {
|
|
797
|
+
running: boolean;
|
|
798
|
+
hintPending: Set<string>;
|
|
799
|
+
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
800
|
+
};
|
|
801
|
+
inner.running = true;
|
|
802
|
+
seedStore(engine, "Document", [{ id: "unshared", title: "No longer shared" }]);
|
|
803
|
+
|
|
804
|
+
inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
|
|
805
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
806
|
+
expect(fetches).toBe(1);
|
|
807
|
+
expect([...inner.hintPending]).toEqual(["Document"]);
|
|
808
|
+
expect(engine.store.list("Document").length).toBe(1);
|
|
809
|
+
|
|
810
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
811
|
+
expect(fetches).toBe(2);
|
|
812
|
+
expect(engine.store.list("Document")).toEqual([]);
|
|
813
|
+
engine.stop();
|
|
814
|
+
});
|
|
815
|
+
});
|