@pylonsync/sync 0.22.43 → 0.22.46
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 -45
- package/package.json +1 -1
- package/src/index.ts +119 -58
- package/src/reconcile.test.ts +210 -6
package/dist/index.d.ts
CHANGED
|
@@ -106,29 +106,6 @@ export interface SyncEngineConfig {
|
|
|
106
106
|
*/
|
|
107
107
|
multiTab?: boolean;
|
|
108
108
|
}
|
|
109
|
-
/**
|
|
110
|
-
* Generate a stable client_id. Prefers a persisted id from `storage`
|
|
111
|
-
* (so a reload keeps the same identifier) and falls back to a fresh UUID.
|
|
112
|
-
*/
|
|
113
|
-
/**
|
|
114
|
-
* Generate a Pylon-shaped row id (40-char lowercase hex).
|
|
115
|
-
*
|
|
116
|
-
* Mirrors the runtime's `generate_id` shape: 32 hex of milliseconds
|
|
117
|
-
* since epoch (extended to nanos so it lex-sorts alongside server-
|
|
118
|
-
* generated ids) + 8 hex of a per-tab counter. Lex-sortable, monotonic
|
|
119
|
-
* within a tab, statistically unique across tabs (the timestamp
|
|
120
|
-
* disambiguates almost every cross-tab collision; the counter handles
|
|
121
|
-
* the rest within a single tick).
|
|
122
|
-
*
|
|
123
|
-
* Used by `useMutation({ optimistic })` to mint client-side ids that
|
|
124
|
-
* the runtime will accept verbatim — so the optimistic ghost and the
|
|
125
|
-
* canonical row share the same `row_id` and the WS broadcast is an
|
|
126
|
-
* idempotent merge instead of a delete-then-replace flash.
|
|
127
|
-
*
|
|
128
|
-
* Apps can call this directly when they need a stable id earlier than
|
|
129
|
-
* the mutation (e.g. to reference the row from another optimistic
|
|
130
|
-
* insert in the same gesture).
|
|
131
|
-
*/
|
|
132
109
|
export declare class SyncEngine {
|
|
133
110
|
private config;
|
|
134
111
|
private cursor;
|
|
@@ -733,34 +710,41 @@ export declare class SyncEngine {
|
|
|
733
710
|
*/
|
|
734
711
|
observeEntity(entity: string): void;
|
|
735
712
|
reconcile(entities?: string[]): Promise<void>;
|
|
736
|
-
/** Entities
|
|
737
|
-
*
|
|
738
|
-
private
|
|
739
|
-
|
|
740
|
-
private
|
|
741
|
-
private
|
|
713
|
+
/** Entities to reconcile that have not reconciled yet: named by a server
|
|
714
|
+
* hint (`sync-reconcile`), or skipped by an earlier reconcile. */
|
|
715
|
+
private pendingReconcile;
|
|
716
|
+
/** Numbers each pending request, so a pass clears only older ones. */
|
|
717
|
+
private pendingReconcileSeq;
|
|
718
|
+
private pendingReconcileTimer;
|
|
719
|
+
private pendingReconcileRetries;
|
|
720
|
+
/** Bumped by stop() and resetReplica(): pending work queued before it
|
|
721
|
+
* does not run. */
|
|
722
|
+
private pendingReconcileEpoch;
|
|
723
|
+
private lastPendingReconcileAt;
|
|
742
724
|
/** Hints that arrive this close together share one reconcile. */
|
|
743
|
-
private static readonly
|
|
725
|
+
private static readonly PENDING_RECONCILE_DEBOUNCE_MS;
|
|
744
726
|
/** At most one hinted reconcile per this interval. */
|
|
745
|
-
private static readonly
|
|
727
|
+
private static readonly PENDING_RECONCILE_MIN_INTERVAL_MS;
|
|
746
728
|
/** Backoff for entities whose fetch raced a newer change: doubling from
|
|
747
729
|
* the base, capped. */
|
|
748
|
-
private static readonly
|
|
749
|
-
private static readonly
|
|
730
|
+
private static readonly PENDING_RECONCILE_RETRY_BASE_MS;
|
|
731
|
+
private static readonly PENDING_RECONCILE_RETRY_MAX_MS;
|
|
750
732
|
/**
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
* merged, run at most once a second, and an entity whose
|
|
756
|
-
* newer change is retried with backoff until it
|
|
757
|
-
* stops.
|
|
733
|
+
* Keep `entities` pending until a reconcile of them completes. Used for
|
|
734
|
+
* server hints (`sync-reconcile`: a share, an unshare, a folder
|
|
735
|
+
* membership changed which rows this user may read, and no other event
|
|
736
|
+
* will tell this replica) and for entities an earlier reconcile skipped.
|
|
737
|
+
* Requests are merged, run at most once a second, and an entity whose
|
|
738
|
+
* fetch failed or raced a newer change is retried with backoff until it
|
|
739
|
+
* lands or the engine stops.
|
|
758
740
|
*/
|
|
759
|
-
private
|
|
760
|
-
private
|
|
761
|
-
private
|
|
762
|
-
/**
|
|
763
|
-
|
|
741
|
+
private queuePendingReconcile;
|
|
742
|
+
private schedulePendingReconcile;
|
|
743
|
+
private flushPendingReconcile;
|
|
744
|
+
/** Drop pending reconciles, including queued ones (stop, replica reset). */
|
|
745
|
+
private cancelPendingReconcile;
|
|
746
|
+
/** `reconcile`, resolving with the entities it reconciled and the ones
|
|
747
|
+
* it skipped (the fetch failed, or the cursor moved during it). */
|
|
764
748
|
private reconcileReportingSkips;
|
|
765
749
|
/** Scoped reconcile batch that has not started running yet. */
|
|
766
750
|
private scopedReconcileBatch;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -230,6 +230,17 @@ export interface SyncEngineConfig {
|
|
|
230
230
|
* insert in the same gesture).
|
|
231
231
|
*/
|
|
232
232
|
|
|
233
|
+
/** What a reconcile pass did per entity. */
|
|
234
|
+
interface ReconcileOutcome {
|
|
235
|
+
/** Reconciled: the replica matches the server for these. */
|
|
236
|
+
done: string[];
|
|
237
|
+
/** Not reconciled: the fetch failed, or a newer change raced it. */
|
|
238
|
+
skipped: string[];
|
|
239
|
+
/** The pending-request sequence when the pass began fetching. Callers
|
|
240
|
+
* that share the pass clear only requests at or below it. */
|
|
241
|
+
startedAt: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
233
244
|
export class SyncEngine {
|
|
234
245
|
private config: SyncEngineConfig;
|
|
235
246
|
private cursor: SyncCursor = { last_seq: 0 };
|
|
@@ -1651,12 +1662,7 @@ export class SyncEngine {
|
|
|
1651
1662
|
/** Stop the sync engine. */
|
|
1652
1663
|
stop(): void {
|
|
1653
1664
|
this.running = false;
|
|
1654
|
-
|
|
1655
|
-
clearTimeout(this.hintTimer);
|
|
1656
|
-
this.hintTimer = null;
|
|
1657
|
-
}
|
|
1658
|
-
this.hintPending.clear();
|
|
1659
|
-
this.hintRetries = 0;
|
|
1665
|
+
this.cancelPendingReconcile();
|
|
1660
1666
|
if (this.identityRetryTimer !== null) {
|
|
1661
1667
|
clearTimeout(this.identityRetryTimer);
|
|
1662
1668
|
this.identityRetryTimer = null;
|
|
@@ -1698,6 +1704,8 @@ export class SyncEngine {
|
|
|
1698
1704
|
* in-memory state could fix.
|
|
1699
1705
|
*/
|
|
1700
1706
|
async resetReplica(opts: { wipeMutations?: boolean } = {}): Promise<void> {
|
|
1707
|
+
// Rows fetched for the old replica must not land in the new one.
|
|
1708
|
+
this.cancelPendingReconcile();
|
|
1701
1709
|
// Public callers go through the queue so a reset can't race with
|
|
1702
1710
|
// an in-flight pull / push / reconcile. Internal callers that
|
|
1703
1711
|
// already hold the queue slot use `resetReplicaInner` directly.
|
|
@@ -2309,75 +2317,123 @@ export class SyncEngine {
|
|
|
2309
2317
|
}
|
|
2310
2318
|
|
|
2311
2319
|
async reconcile(entities?: string[]): Promise<void> {
|
|
2312
|
-
|
|
2320
|
+
const epoch = this.pendingReconcileEpoch;
|
|
2321
|
+
const { done, skipped, startedAt } = await this.reconcileReportingSkips(entities);
|
|
2322
|
+
// stop() or resetReplica() ran meanwhile: leave pending work alone.
|
|
2323
|
+
if (epoch !== this.pendingReconcileEpoch) return;
|
|
2324
|
+
// An entity skipped because its fetch failed or raced a newer change
|
|
2325
|
+
// stays pending until a reconcile of it completes. Otherwise a row that
|
|
2326
|
+
// became readable while this replica was away (a share, a new folder)
|
|
2327
|
+
// stays missing until the next focus or reconnect.
|
|
2328
|
+
// Clear only requests this pass covers: one made after it started (a
|
|
2329
|
+
// hint during the fetch) may name rows its response does not hold.
|
|
2330
|
+
for (const e of done) {
|
|
2331
|
+
const requestedAt = this.pendingReconcile.get(e);
|
|
2332
|
+
if (requestedAt !== undefined && requestedAt <= startedAt) this.pendingReconcile.delete(e);
|
|
2333
|
+
}
|
|
2334
|
+
if (skipped.length > 0) this.queuePendingReconcile(skipped);
|
|
2313
2335
|
}
|
|
2314
2336
|
|
|
2315
|
-
/** Entities
|
|
2316
|
-
*
|
|
2317
|
-
private
|
|
2318
|
-
|
|
2319
|
-
private
|
|
2320
|
-
private
|
|
2337
|
+
/** Entities to reconcile that have not reconciled yet: named by a server
|
|
2338
|
+
* hint (`sync-reconcile`), or skipped by an earlier reconcile. */
|
|
2339
|
+
private pendingReconcile = new Map<string, number>();
|
|
2340
|
+
/** Numbers each pending request, so a pass clears only older ones. */
|
|
2341
|
+
private pendingReconcileSeq = 0;
|
|
2342
|
+
private pendingReconcileTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2343
|
+
private pendingReconcileRetries = 0;
|
|
2344
|
+
/** Bumped by stop() and resetReplica(): pending work queued before it
|
|
2345
|
+
* does not run. */
|
|
2346
|
+
private pendingReconcileEpoch = 0;
|
|
2347
|
+
private lastPendingReconcileAt = 0;
|
|
2321
2348
|
/** Hints that arrive this close together share one reconcile. */
|
|
2322
|
-
private static readonly
|
|
2349
|
+
private static readonly PENDING_RECONCILE_DEBOUNCE_MS = 100;
|
|
2323
2350
|
/** At most one hinted reconcile per this interval. */
|
|
2324
|
-
private static readonly
|
|
2351
|
+
private static readonly PENDING_RECONCILE_MIN_INTERVAL_MS = 1_000;
|
|
2325
2352
|
/** Backoff for entities whose fetch raced a newer change: doubling from
|
|
2326
2353
|
* the base, capped. */
|
|
2327
|
-
private static readonly
|
|
2328
|
-
private static readonly
|
|
2354
|
+
private static readonly PENDING_RECONCILE_RETRY_BASE_MS = 250;
|
|
2355
|
+
private static readonly PENDING_RECONCILE_RETRY_MAX_MS = 10_000;
|
|
2329
2356
|
|
|
2330
2357
|
/**
|
|
2331
|
-
*
|
|
2332
|
-
*
|
|
2333
|
-
*
|
|
2334
|
-
*
|
|
2335
|
-
* merged, run at most once a second, and an entity whose
|
|
2336
|
-
* newer change is retried with backoff until it
|
|
2337
|
-
* stops.
|
|
2358
|
+
* Keep `entities` pending until a reconcile of them completes. Used for
|
|
2359
|
+
* server hints (`sync-reconcile`: a share, an unshare, a folder
|
|
2360
|
+
* membership changed which rows this user may read, and no other event
|
|
2361
|
+
* will tell this replica) and for entities an earlier reconcile skipped.
|
|
2362
|
+
* Requests are merged, run at most once a second, and an entity whose
|
|
2363
|
+
* fetch failed or raced a newer change is retried with backoff until it
|
|
2364
|
+
* lands or the engine stops.
|
|
2338
2365
|
*/
|
|
2339
|
-
private
|
|
2340
|
-
for (const e of entities) this.
|
|
2341
|
-
this.
|
|
2366
|
+
private queuePendingReconcile(entities: string[]): void {
|
|
2367
|
+
for (const e of entities) this.pendingReconcile.set(e, ++this.pendingReconcileSeq);
|
|
2368
|
+
this.schedulePendingReconcile(SyncEngine.PENDING_RECONCILE_DEBOUNCE_MS);
|
|
2342
2369
|
}
|
|
2343
2370
|
|
|
2344
|
-
private
|
|
2345
|
-
if (this.
|
|
2346
|
-
const wait = Math.max(delayMs, this.
|
|
2347
|
-
this.
|
|
2348
|
-
this.
|
|
2349
|
-
void this.
|
|
2371
|
+
private schedulePendingReconcile(delayMs: number): void {
|
|
2372
|
+
if (this.pendingReconcileTimer !== null || !this.running) return;
|
|
2373
|
+
const wait = Math.max(delayMs, this.lastPendingReconcileAt + SyncEngine.PENDING_RECONCILE_MIN_INTERVAL_MS - Date.now());
|
|
2374
|
+
this.pendingReconcileTimer = setTimeout(() => {
|
|
2375
|
+
this.pendingReconcileTimer = null;
|
|
2376
|
+
void this.flushPendingReconcile();
|
|
2350
2377
|
}, wait);
|
|
2351
2378
|
}
|
|
2352
2379
|
|
|
2353
|
-
private async
|
|
2354
|
-
if (!this.running || this.
|
|
2355
|
-
const
|
|
2356
|
-
this.
|
|
2357
|
-
this.
|
|
2380
|
+
private async flushPendingReconcile(): Promise<void> {
|
|
2381
|
+
if (!this.running || this.pendingReconcile.size === 0) return;
|
|
2382
|
+
const epoch = this.pendingReconcileEpoch;
|
|
2383
|
+
const entities = [...this.pendingReconcile.keys()];
|
|
2384
|
+
this.pendingReconcile.clear();
|
|
2385
|
+
this.lastPendingReconcileAt = Date.now();
|
|
2358
2386
|
let skipped: string[];
|
|
2359
2387
|
try {
|
|
2360
|
-
|
|
2388
|
+
// Queued like any reconcile, and dropped if stop() or resetReplica()
|
|
2389
|
+
// ran while it waited.
|
|
2390
|
+
const result = await this.opQueue.enqueue(`reconcile-pending:${epoch}:${Date.now()}`, async () => {
|
|
2391
|
+
if (epoch !== this.pendingReconcileEpoch || !this.running) return null;
|
|
2392
|
+
try {
|
|
2393
|
+
return await this.reconcileInner(entities);
|
|
2394
|
+
} finally {
|
|
2395
|
+
this.lastReconcileAt = Date.now();
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
if (result === null) return;
|
|
2399
|
+
skipped = result.skipped;
|
|
2361
2400
|
} catch {
|
|
2362
2401
|
skipped = entities;
|
|
2363
2402
|
}
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2403
|
+
if (epoch !== this.pendingReconcileEpoch) return;
|
|
2404
|
+
for (const e of skipped) {
|
|
2405
|
+
if (!this.pendingReconcile.has(e)) this.pendingReconcile.set(e, ++this.pendingReconcileSeq);
|
|
2406
|
+
}
|
|
2407
|
+
this.pendingReconcileRetries = skipped.length > 0 ? this.pendingReconcileRetries + 1 : 0;
|
|
2408
|
+
if (this.pendingReconcile.size === 0) return;
|
|
2367
2409
|
const delay =
|
|
2368
2410
|
skipped.length > 0
|
|
2369
|
-
? Math.min(
|
|
2370
|
-
|
|
2371
|
-
|
|
2411
|
+
? Math.min(
|
|
2412
|
+
SyncEngine.PENDING_RECONCILE_RETRY_BASE_MS * 2 ** (this.pendingReconcileRetries - 1),
|
|
2413
|
+
SyncEngine.PENDING_RECONCILE_RETRY_MAX_MS,
|
|
2414
|
+
)
|
|
2415
|
+
: SyncEngine.PENDING_RECONCILE_DEBOUNCE_MS;
|
|
2416
|
+
this.schedulePendingReconcile(delay);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
/** Drop pending reconciles, including queued ones (stop, replica reset). */
|
|
2420
|
+
private cancelPendingReconcile(): void {
|
|
2421
|
+
this.pendingReconcileEpoch += 1;
|
|
2422
|
+
if (this.pendingReconcileTimer !== null) {
|
|
2423
|
+
clearTimeout(this.pendingReconcileTimer);
|
|
2424
|
+
this.pendingReconcileTimer = null;
|
|
2425
|
+
}
|
|
2426
|
+
this.pendingReconcile.clear();
|
|
2427
|
+
this.pendingReconcileRetries = 0;
|
|
2372
2428
|
}
|
|
2373
2429
|
|
|
2374
|
-
/** `reconcile`, resolving with the entities
|
|
2375
|
-
* moved during
|
|
2376
|
-
private async reconcileReportingSkips(entities?: string[]): Promise<
|
|
2430
|
+
/** `reconcile`, resolving with the entities it reconciled and the ones
|
|
2431
|
+
* it skipped (the fetch failed, or the cursor moved during it). */
|
|
2432
|
+
private async reconcileReportingSkips(entities?: string[]): Promise<ReconcileOutcome> {
|
|
2377
2433
|
const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
|
|
2378
2434
|
const now = Date.now();
|
|
2379
2435
|
if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
|
|
2380
|
-
return [];
|
|
2436
|
+
return { done: [], skipped: [], startedAt: this.pendingReconcileSeq };
|
|
2381
2437
|
}
|
|
2382
2438
|
// Reconcile waits behind any in-flight pull / refresh / push so it
|
|
2383
2439
|
// can't apply rows captured under a stale session.
|
|
@@ -2401,9 +2457,9 @@ export class SyncEngine {
|
|
|
2401
2457
|
for (const e of entities) open.entities.add(e);
|
|
2402
2458
|
return open.promise;
|
|
2403
2459
|
}
|
|
2404
|
-
const batch: { entities: Set<string>; promise: Promise<
|
|
2460
|
+
const batch: { entities: Set<string>; promise: Promise<ReconcileOutcome> } = {
|
|
2405
2461
|
entities: new Set(entities),
|
|
2406
|
-
promise: Promise.resolve([]),
|
|
2462
|
+
promise: Promise.resolve({ done: [], skipped: [], startedAt: this.pendingReconcileSeq }),
|
|
2407
2463
|
};
|
|
2408
2464
|
this.scopedReconcileBatch = batch;
|
|
2409
2465
|
this.scopedReconcileSeq += 1;
|
|
@@ -2424,14 +2480,15 @@ export class SyncEngine {
|
|
|
2424
2480
|
/** Scoped reconcile batch that has not started running yet. */
|
|
2425
2481
|
private scopedReconcileBatch: {
|
|
2426
2482
|
entities: Set<string>;
|
|
2427
|
-
promise: Promise<
|
|
2483
|
+
promise: Promise<ReconcileOutcome>;
|
|
2428
2484
|
} | null = null;
|
|
2429
2485
|
private scopedReconcileSeq = 0;
|
|
2430
2486
|
|
|
2431
|
-
private async reconcileInner(entities?: string[]): Promise<
|
|
2487
|
+
private async reconcileInner(entities?: string[]): Promise<ReconcileOutcome> {
|
|
2488
|
+
const startedAt = this.pendingReconcileSeq;
|
|
2432
2489
|
// Same reasoning as pullInner: the leader reconciles, broadcasts
|
|
2433
2490
|
// results, and follower replicas converge via the channel.
|
|
2434
|
-
if (!this.isMultiTabLeader) return [];
|
|
2491
|
+
if (!this.isMultiTabLeader) return { done: [], skipped: [], startedAt };
|
|
2435
2492
|
// Sweep entities with local rows PLUS entities the app has observed
|
|
2436
2493
|
// via useQuery (even when empty locally). Without the observed set,
|
|
2437
2494
|
// a server row in a never-cached entity is never reconciled and
|
|
@@ -2439,8 +2496,9 @@ export class SyncEngine {
|
|
|
2439
2496
|
const names =
|
|
2440
2497
|
entities ??
|
|
2441
2498
|
[...new Set([...this.store.entityNames(), ...this.observedEntities])];
|
|
2442
|
-
if (names.length === 0) return [];
|
|
2499
|
+
if (names.length === 0) return { done: [], skipped: [], startedAt };
|
|
2443
2500
|
const skipped: string[] = [];
|
|
2501
|
+
const done: string[] = [];
|
|
2444
2502
|
// Tombstone seq for any local row the server doesn't return. Using
|
|
2445
2503
|
// the current cursor means future inserts (which have higher seqs)
|
|
2446
2504
|
// bypass the tombstone — re-creation server-side still propagates.
|
|
@@ -2495,6 +2553,7 @@ export class SyncEngine {
|
|
|
2495
2553
|
// Entity removed from the manifest — definitive. Drop its rows.
|
|
2496
2554
|
this.reconcile403Streak.delete(entity);
|
|
2497
2555
|
await this.dropEntity(entity, tombstoneSeq);
|
|
2556
|
+
done.push(entity);
|
|
2498
2557
|
return;
|
|
2499
2558
|
}
|
|
2500
2559
|
if (status === 403) {
|
|
@@ -2521,6 +2580,7 @@ export class SyncEngine {
|
|
|
2521
2580
|
}
|
|
2522
2581
|
this.reconcile403Streak.delete(entity);
|
|
2523
2582
|
await this.dropEntity(entity, tombstoneSeq);
|
|
2583
|
+
done.push(entity);
|
|
2524
2584
|
return;
|
|
2525
2585
|
}
|
|
2526
2586
|
// Not reconciled (offline, a 5xx): reported, so a server hint
|
|
@@ -2538,7 +2598,7 @@ export class SyncEngine {
|
|
|
2538
2598
|
// entity; reconcile() is triggered again on visibility-change
|
|
2539
2599
|
// and reconnect, and the WS event already carried the latest
|
|
2540
2600
|
// state for the affected row. Reported, so a server hint can
|
|
2541
|
-
// retry (
|
|
2601
|
+
// retry (flushPendingReconcile).
|
|
2542
2602
|
skipped.push(entity);
|
|
2543
2603
|
return;
|
|
2544
2604
|
}
|
|
@@ -2556,9 +2616,10 @@ export class SyncEngine {
|
|
|
2556
2616
|
tombstoneSeq,
|
|
2557
2617
|
fetchTruncated,
|
|
2558
2618
|
);
|
|
2619
|
+
done.push(entity);
|
|
2559
2620
|
}),
|
|
2560
2621
|
);
|
|
2561
|
-
return skipped;
|
|
2622
|
+
return { done, skipped, startedAt };
|
|
2562
2623
|
}
|
|
2563
2624
|
|
|
2564
2625
|
/** Fetch every row for an entity. Uses cursor pagination so big tables
|
|
@@ -4190,7 +4251,7 @@ export class SyncEngine {
|
|
|
4190
4251
|
// change event for the rows themselves. Reconcile them.
|
|
4191
4252
|
if (msg.type === "sync-reconcile" && Array.isArray(msg.entities)) {
|
|
4192
4253
|
const entities = msg.entities.filter((e): e is string => typeof e === "string");
|
|
4193
|
-
if (entities.length > 0) this.
|
|
4254
|
+
if (entities.length > 0) this.queuePendingReconcile(entities);
|
|
4194
4255
|
return;
|
|
4195
4256
|
}
|
|
4196
4257
|
|
package/src/reconcile.test.ts
CHANGED
|
@@ -684,7 +684,7 @@ describe("sync-reconcile hint", () => {
|
|
|
684
684
|
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
685
685
|
running: boolean;
|
|
686
686
|
cursor: { last_seq: number };
|
|
687
|
-
|
|
687
|
+
pendingReconcile: Map<string, number>;
|
|
688
688
|
};
|
|
689
689
|
const inner = (engine: SyncEngine) => engine as unknown as Internals;
|
|
690
690
|
const deliver = (engine: SyncEngine, msg: Record<string, unknown>) => inner(engine).dispatchInboundMessage(msg);
|
|
@@ -742,13 +742,13 @@ describe("sync-reconcile hint", () => {
|
|
|
742
742
|
await sleep(250);
|
|
743
743
|
expect(fetches()).toBe(1);
|
|
744
744
|
expect(engine.store.list("Document")).toEqual([]);
|
|
745
|
-
expect([...inner(engine).
|
|
745
|
+
expect([...inner(engine).pendingReconcile.keys()]).toEqual(["Document"]);
|
|
746
746
|
|
|
747
747
|
// Retries run at most once a second.
|
|
748
748
|
await sleep(2300);
|
|
749
749
|
expect(fetches()).toBe(3);
|
|
750
750
|
expect(engine.store.list("Document").map((r) => (r as { id: string }).id)).toEqual(["shared"]);
|
|
751
|
-
expect(inner(engine).
|
|
751
|
+
expect(inner(engine).pendingReconcile.size).toBe(0);
|
|
752
752
|
engine.stop();
|
|
753
753
|
});
|
|
754
754
|
|
|
@@ -760,7 +760,7 @@ describe("sync-reconcile hint", () => {
|
|
|
760
760
|
engine.stop();
|
|
761
761
|
await sleep(250);
|
|
762
762
|
expect(fetches()).toBe(0);
|
|
763
|
-
expect(inner(engine).
|
|
763
|
+
expect(inner(engine).pendingReconcile.size).toBe(0);
|
|
764
764
|
});
|
|
765
765
|
|
|
766
766
|
test("ignores a hint without entity names", async () => {
|
|
@@ -795,7 +795,7 @@ describe("sync-reconcile hint after a failed fetch", () => {
|
|
|
795
795
|
const engine = makeEngine();
|
|
796
796
|
const inner = engine as unknown as {
|
|
797
797
|
running: boolean;
|
|
798
|
-
|
|
798
|
+
pendingReconcile: Map<string, number>;
|
|
799
799
|
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
800
800
|
};
|
|
801
801
|
inner.running = true;
|
|
@@ -804,7 +804,7 @@ describe("sync-reconcile hint after a failed fetch", () => {
|
|
|
804
804
|
inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
|
|
805
805
|
await new Promise((r) => setTimeout(r, 250));
|
|
806
806
|
expect(fetches).toBe(1);
|
|
807
|
-
expect([...inner.
|
|
807
|
+
expect([...inner.pendingReconcile.keys()]).toEqual(["Document"]);
|
|
808
808
|
expect(engine.store.list("Document").length).toBe(1);
|
|
809
809
|
|
|
810
810
|
await new Promise((r) => setTimeout(r, 1200));
|
|
@@ -813,3 +813,207 @@ describe("sync-reconcile hint after a failed fetch", () => {
|
|
|
813
813
|
engine.stop();
|
|
814
814
|
});
|
|
815
815
|
});
|
|
816
|
+
|
|
817
|
+
// Any reconcile, not only a hinted one: an entity skipped because its fetch
|
|
818
|
+
// raced a newer change is retried, so a row that became readable while the
|
|
819
|
+
// page was away (a new folder, a share) still arrives.
|
|
820
|
+
describe("a skipped reconcile retries", () => {
|
|
821
|
+
let restore: (() => void) | null = null;
|
|
822
|
+
afterEach(() => {
|
|
823
|
+
restore?.();
|
|
824
|
+
restore = null;
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("a startup reconcile that raced a change lands on the retry", async () => {
|
|
828
|
+
const engine = makeEngine();
|
|
829
|
+
const inner = engine as unknown as { running: boolean; cursor: { last_seq: number }; pendingReconcile: Set<string> };
|
|
830
|
+
let fetches = 0;
|
|
831
|
+
restore = installFetch(async (url) => {
|
|
832
|
+
if (url.includes("/api/entities/Folder/cursor")) {
|
|
833
|
+
fetches += 1;
|
|
834
|
+
if (fetches === 1) inner.cursor.last_seq += 1;
|
|
835
|
+
return { status: 200, body: { data: [{ id: "f1", name: "Team" }], next_cursor: null, has_more: false } };
|
|
836
|
+
}
|
|
837
|
+
return { status: 404, body: {} };
|
|
838
|
+
});
|
|
839
|
+
inner.running = true;
|
|
840
|
+
|
|
841
|
+
await engine.reconcile(["Folder"]);
|
|
842
|
+
expect(engine.store.list("Folder")).toEqual([]);
|
|
843
|
+
expect([...inner.pendingReconcile.keys()]).toEqual(["Folder"]);
|
|
844
|
+
|
|
845
|
+
await new Promise((r) => setTimeout(r, 1300));
|
|
846
|
+
expect(fetches).toBe(2);
|
|
847
|
+
expect(engine.store.list("Folder").map((r) => (r as { id: string }).id)).toEqual(["f1"]);
|
|
848
|
+
engine.stop();
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
describe("pending reconciles end when they should", () => {
|
|
853
|
+
let restore: (() => void) | null = null;
|
|
854
|
+
afterEach(() => {
|
|
855
|
+
restore?.();
|
|
856
|
+
restore = null;
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
type Internals = {
|
|
860
|
+
running: boolean;
|
|
861
|
+
pendingReconcile: Map<string, number>;
|
|
862
|
+
opQueue: { enqueue<T>(key: string, fn: () => Promise<T>): Promise<T> };
|
|
863
|
+
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
864
|
+
};
|
|
865
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
866
|
+
|
|
867
|
+
function server(status: () => number) {
|
|
868
|
+
let fetches = 0;
|
|
869
|
+
restore = installFetch(async (url) => {
|
|
870
|
+
if (url.includes("/api/entities/Document/cursor")) {
|
|
871
|
+
fetches += 1;
|
|
872
|
+
const s = status();
|
|
873
|
+
if (s !== 200) return { status: s, body: {} };
|
|
874
|
+
return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
|
|
875
|
+
}
|
|
876
|
+
return { status: 404, body: {} };
|
|
877
|
+
});
|
|
878
|
+
return () => fetches;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
test("a later successful reconcile clears the pending retry", async () => {
|
|
882
|
+
let failing = true;
|
|
883
|
+
const fetches = server(() => (failing ? 503 : 200));
|
|
884
|
+
const engine = makeEngine();
|
|
885
|
+
const inner = engine as unknown as Internals;
|
|
886
|
+
inner.running = true;
|
|
887
|
+
await engine.reconcile(["Document"]);
|
|
888
|
+
expect([...inner.pendingReconcile.keys()]).toEqual(["Document"]);
|
|
889
|
+
failing = false;
|
|
890
|
+
await engine.reconcile(["Document"]);
|
|
891
|
+
expect(inner.pendingReconcile.size).toBe(0);
|
|
892
|
+
await sleep(1300);
|
|
893
|
+
expect(fetches()).toBe(2);
|
|
894
|
+
engine.stop();
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
test("stop cancels a retry already waiting in the queue", async () => {
|
|
898
|
+
const fetches = server(() => 200);
|
|
899
|
+
const engine = makeEngine();
|
|
900
|
+
const inner = engine as unknown as Internals;
|
|
901
|
+
inner.running = true;
|
|
902
|
+
// A slow operation holds the queue while the retry's timer fires.
|
|
903
|
+
void inner.opQueue.enqueue("slow", () => sleep(400));
|
|
904
|
+
inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
|
|
905
|
+
await sleep(200);
|
|
906
|
+
engine.stop();
|
|
907
|
+
await sleep(400);
|
|
908
|
+
expect(fetches()).toBe(0);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("a replica reset drops pending retries", async () => {
|
|
912
|
+
const fetches = server(() => 503);
|
|
913
|
+
const engine = makeEngine();
|
|
914
|
+
const inner = engine as unknown as Internals;
|
|
915
|
+
inner.running = true;
|
|
916
|
+
await engine.reconcile(["Document"]);
|
|
917
|
+
expect(fetches()).toBe(1);
|
|
918
|
+
await engine.resetReplica();
|
|
919
|
+
expect(inner.pendingReconcile.size).toBe(0);
|
|
920
|
+
await sleep(1300);
|
|
921
|
+
expect(fetches()).toBe(1);
|
|
922
|
+
engine.stop();
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
describe("pending reconciles and newer requests", () => {
|
|
927
|
+
let restore: (() => void) | null = null;
|
|
928
|
+
afterEach(() => {
|
|
929
|
+
restore?.();
|
|
930
|
+
restore = null;
|
|
931
|
+
});
|
|
932
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
933
|
+
type Internals = {
|
|
934
|
+
running: boolean;
|
|
935
|
+
pendingReconcile: Map<string, number>;
|
|
936
|
+
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
test("a hint that arrives during a fetch survives that fetch", async () => {
|
|
940
|
+
const engine = makeEngine();
|
|
941
|
+
const inner = engine as unknown as Internals;
|
|
942
|
+
let fetches = 0;
|
|
943
|
+
restore = installFetch(async (url) => {
|
|
944
|
+
if (url.includes("/api/entities/Document/cursor")) {
|
|
945
|
+
fetches += 1;
|
|
946
|
+
// The server already built this response when the share lands.
|
|
947
|
+
if (fetches === 1) inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
|
|
948
|
+
return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
|
|
949
|
+
}
|
|
950
|
+
return { status: 404, body: {} };
|
|
951
|
+
});
|
|
952
|
+
inner.running = true;
|
|
953
|
+
await engine.reconcile(["Document"]);
|
|
954
|
+
expect([...inner.pendingReconcile.keys()]).toEqual(["Document"]);
|
|
955
|
+
await sleep(1300);
|
|
956
|
+
expect(fetches).toBe(2);
|
|
957
|
+
engine.stop();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("a reconcile in flight during a reset does not bring retries back", async () => {
|
|
961
|
+
const engine = makeEngine();
|
|
962
|
+
const inner = engine as unknown as Internals;
|
|
963
|
+
let fetches = 0;
|
|
964
|
+
let release: () => void = () => {};
|
|
965
|
+
restore = installFetch(async (url) => {
|
|
966
|
+
if (url.includes("/api/entities/Document/cursor")) {
|
|
967
|
+
fetches += 1;
|
|
968
|
+
if (fetches === 1) await new Promise<void>((r) => (release = r));
|
|
969
|
+
return { status: 503, body: {} };
|
|
970
|
+
}
|
|
971
|
+
return { status: 404, body: {} };
|
|
972
|
+
});
|
|
973
|
+
inner.running = true;
|
|
974
|
+
const pass = engine.reconcile(["Document"]);
|
|
975
|
+
await sleep(20);
|
|
976
|
+
const reset = engine.resetReplica();
|
|
977
|
+
release();
|
|
978
|
+
await pass;
|
|
979
|
+
await reset;
|
|
980
|
+
expect(inner.pendingReconcile.size).toBe(0);
|
|
981
|
+
await sleep(1300);
|
|
982
|
+
expect(fetches).toBe(1);
|
|
983
|
+
engine.stop();
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
test("two callers sharing one full reconcile keep a hint that arrived between them", async () => {
|
|
988
|
+
const engine = makeEngine();
|
|
989
|
+
const inner = engine as unknown as {
|
|
990
|
+
running: boolean;
|
|
991
|
+
pendingReconcile: Map<string, number>;
|
|
992
|
+
store: { applyChange(ev: ChangeEvent): void };
|
|
993
|
+
dispatchInboundMessage(m: Record<string, unknown>): void;
|
|
994
|
+
};
|
|
995
|
+
seedStore(engine, "Document", [{ id: "d0", title: "seed" }]);
|
|
996
|
+
let fetches = 0;
|
|
997
|
+
let release: () => void = () => {};
|
|
998
|
+
const restore = installFetch(async (url) => {
|
|
999
|
+
if (url.includes("/api/entities/Document/cursor")) {
|
|
1000
|
+
fetches += 1;
|
|
1001
|
+
if (fetches === 1) await new Promise<void>((r) => (release = r));
|
|
1002
|
+
return { status: 200, body: { data: [{ id: "d0", title: "seed" }], next_cursor: null, has_more: false } };
|
|
1003
|
+
}
|
|
1004
|
+
return { status: 404, body: {} };
|
|
1005
|
+
});
|
|
1006
|
+
try {
|
|
1007
|
+
inner.running = true;
|
|
1008
|
+
const first = engine.reconcile();
|
|
1009
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
1010
|
+
inner.dispatchInboundMessage({ type: "sync-reconcile", entities: ["Document"] });
|
|
1011
|
+
const second = engine.reconcile();
|
|
1012
|
+
release();
|
|
1013
|
+
await Promise.all([first, second]);
|
|
1014
|
+
expect([...inner.pendingReconcile.keys()]).toEqual(["Document"]);
|
|
1015
|
+
} finally {
|
|
1016
|
+
engine.stop();
|
|
1017
|
+
restore();
|
|
1018
|
+
}
|
|
1019
|
+
});
|