@spooky-sync/core 0.0.1-canary.221 → 0.0.1-canary.223
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
CHANGED
|
@@ -882,8 +882,21 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
882
882
|
* rolls the mutation back and never reports it settled, so it vanishes at
|
|
883
883
|
* once. This deadline only bounds the case where the write succeeded and its
|
|
884
884
|
* membership never arrived at all.
|
|
885
|
+
*
|
|
886
|
+
* 30 s, up from 10 s. At 10 s every server hiccup longer than a scheduler
|
|
887
|
+
* drain plus one slow database commit showed up as the user's own chat
|
|
888
|
+
* message vanishing and coming back a minute later. 30 s covers a drain and
|
|
889
|
+
* a stalled commit end to end; a membership that takes longer than that is
|
|
890
|
+
* a server fault the admin heartbeat reports, not something to paper over.
|
|
885
891
|
*/
|
|
886
892
|
private static readonly SETTLED_WRITE_GRACE_MS;
|
|
893
|
+
/**
|
|
894
|
+
* Whether any settled write or delete is still waiting for membership to
|
|
895
|
+
* catch up. The sync poll keeps its fast cadence while this is true, so a
|
|
896
|
+
* missed LIVE notification for the edge is picked up on the next poll tick
|
|
897
|
+
* rather than after the idle backoff.
|
|
898
|
+
*/
|
|
899
|
+
hasSettledWritesPending(): boolean;
|
|
887
900
|
/**
|
|
888
901
|
* Report that a mutation was accepted by the server and its outbox row
|
|
889
902
|
* removed. Called only on the SUCCESS path — a rolled-back mutation must
|
package/dist/index.js
CHANGED
|
@@ -3473,6 +3473,14 @@ function phaseStatOf(samples, lastMs) {
|
|
|
3473
3473
|
count: samples.length
|
|
3474
3474
|
};
|
|
3475
3475
|
}
|
|
3476
|
+
/**
|
|
3477
|
+
* Fraction of a query's TTL after which the client refreshes `lastActiveAt`.
|
|
3478
|
+
*
|
|
3479
|
+
* Must leave room for at least one retry: the server sweeps a view — and its
|
|
3480
|
+
* `_00_list_ref` edges — the moment `lastActiveAt + ttl` passes, and the client
|
|
3481
|
+
* is LIVE on those edges, so a swept-but-still-watched view empties the UI.
|
|
3482
|
+
*/
|
|
3483
|
+
const TTL_HEARTBEAT_FRACTION = .5;
|
|
3476
3484
|
var DataModule = class DataModule {
|
|
3477
3485
|
/** Tab identity baked into mutation ids (shared-tabs rollback routing);
|
|
3478
3486
|
* undefined in solo mode, where mutation-id falls back to a session id. */
|
|
@@ -3855,8 +3863,24 @@ var DataModule = class DataModule {
|
|
|
3855
3863
|
* rolls the mutation back and never reports it settled, so it vanishes at
|
|
3856
3864
|
* once. This deadline only bounds the case where the write succeeded and its
|
|
3857
3865
|
* membership never arrived at all.
|
|
3866
|
+
*
|
|
3867
|
+
* 30 s, up from 10 s. At 10 s every server hiccup longer than a scheduler
|
|
3868
|
+
* drain plus one slow database commit showed up as the user's own chat
|
|
3869
|
+
* message vanishing and coming back a minute later. 30 s covers a drain and
|
|
3870
|
+
* a stalled commit end to end; a membership that takes longer than that is
|
|
3871
|
+
* a server fault the admin heartbeat reports, not something to paper over.
|
|
3858
3872
|
*/
|
|
3859
|
-
static SETTLED_WRITE_GRACE_MS =
|
|
3873
|
+
static SETTLED_WRITE_GRACE_MS = 3e4;
|
|
3874
|
+
/**
|
|
3875
|
+
* Whether any settled write or delete is still waiting for membership to
|
|
3876
|
+
* catch up. The sync poll keeps its fast cadence while this is true, so a
|
|
3877
|
+
* missed LIVE notification for the edge is picked up on the next poll tick
|
|
3878
|
+
* rather than after the idle backoff.
|
|
3879
|
+
*/
|
|
3880
|
+
hasSettledWritesPending() {
|
|
3881
|
+
this.pruneSettled(Date.now());
|
|
3882
|
+
return this.settledWrites.size > 0 || this.settledDeletes.size > 0;
|
|
3883
|
+
}
|
|
3860
3884
|
/**
|
|
3861
3885
|
* Report that a mutation was accepted by the server and its outbox row
|
|
3862
3886
|
* removed. Called only on the SUCCESS path — a rolled-back mutation must
|
|
@@ -5023,7 +5047,7 @@ var DataModule = class DataModule {
|
|
|
5023
5047
|
}
|
|
5024
5048
|
startTTLHeartbeat(queryState, hash) {
|
|
5025
5049
|
if (queryState.ttlTimer) return;
|
|
5026
|
-
const heartbeatTime = Math.floor(queryState.ttlDurationMs *
|
|
5050
|
+
const heartbeatTime = Math.floor(queryState.ttlDurationMs * TTL_HEARTBEAT_FRACTION);
|
|
5027
5051
|
queryState.ttlTimer = setTimeout(() => {
|
|
5028
5052
|
queryState.ttlTimer = null;
|
|
5029
5053
|
if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
|
|
@@ -6620,7 +6644,8 @@ var Sp00kySync = class Sp00kySync {
|
|
|
6620
6644
|
} finally {
|
|
6621
6645
|
this.listRefPollInFlight = null;
|
|
6622
6646
|
if (!this.listRefPollRunning) return;
|
|
6623
|
-
|
|
6647
|
+
const awaitingMembership = this.dataModule.hasSettledWritesPending();
|
|
6648
|
+
this.listRefIdleStreak = changed || awaitingMembership ? 0 : this.listRefIdleStreak + 1;
|
|
6624
6649
|
schedule(listRefPollDelayMs({
|
|
6625
6650
|
idleStreak: this.listRefIdleStreak,
|
|
6626
6651
|
baseIntervalMs: this.refSyncIntervalMs
|
|
@@ -7735,8 +7760,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7735
7760
|
|
|
7736
7761
|
//#endregion
|
|
7737
7762
|
//#region src/modules/devtools/index.ts
|
|
7738
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7739
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7763
|
+
const CORE_VERSION = "0.0.1-canary.223";
|
|
7764
|
+
const WASM_VERSION = "0.0.1-canary.223";
|
|
7740
7765
|
const SURREAL_VERSION = "3.0.3";
|
|
7741
7766
|
var DevToolsService = class DevToolsService {
|
|
7742
7767
|
eventsHistory = [];
|
|
@@ -12751,7 +12776,7 @@ var Sp00kyClient = class {
|
|
|
12751
12776
|
return new TabsCoordinator({
|
|
12752
12777
|
tabId,
|
|
12753
12778
|
fingerprint: computeTabsFingerprint({
|
|
12754
|
-
coreVersion: "0.0.1-canary.
|
|
12779
|
+
coreVersion: "0.0.1-canary.223",
|
|
12755
12780
|
schemaHash: hash53(this.config.schemaSurql),
|
|
12756
12781
|
endpoint: this.config.database.endpoint ?? "",
|
|
12757
12782
|
namespace: this.config.database.namespace,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.223",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.223",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.223",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"blurhash": "^2.0.5",
|
|
@@ -169,9 +169,38 @@ describe('settled-write grace window', () => {
|
|
|
169
169
|
});
|
|
170
170
|
dm.noteWriteSettled('comment:new', 'create');
|
|
171
171
|
expect(ids(await materialize(dm, state))).toContain('comment:new');
|
|
172
|
+
expect(dm.hasSettledWritesPending()).toBe(true);
|
|
172
173
|
|
|
174
|
+
// A scheduler drain plus one stalled database commit fit inside the
|
|
175
|
+
// window: at 11 s the row is still the user's own message, not a ghost.
|
|
173
176
|
vi.advanceTimersByTime(11_000);
|
|
177
|
+
expect(ids(await materialize(dm, state))).toContain('comment:new');
|
|
178
|
+
expect(dm.hasSettledWritesPending()).toBe(true);
|
|
179
|
+
|
|
180
|
+
vi.advanceTimersByTime(20_000);
|
|
174
181
|
expect(ids(await materialize(dm, state))).toEqual(['comment:old']);
|
|
182
|
+
expect(dm.hasSettledWritesPending()).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('reports no pending settled write once membership has caught up', async () => {
|
|
186
|
+
const { dm, state } = setup({
|
|
187
|
+
remoteArray: [['comment:old', 1]],
|
|
188
|
+
localArray: [
|
|
189
|
+
['comment:old', 1],
|
|
190
|
+
['comment:new', 1],
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
expect(dm.hasSettledWritesPending()).toBe(false);
|
|
194
|
+
|
|
195
|
+
dm.noteWriteSettled('comment:new', 'create');
|
|
196
|
+
expect(dm.hasSettledWritesPending()).toBe(true);
|
|
197
|
+
|
|
198
|
+
state.config.remoteArray = [
|
|
199
|
+
['comment:old', 1],
|
|
200
|
+
['comment:new', 1],
|
|
201
|
+
];
|
|
202
|
+
await materialize(dm, state);
|
|
203
|
+
expect(dm.hasSettledWritesPending()).toBe(false);
|
|
175
204
|
});
|
|
176
205
|
|
|
177
206
|
it('keeps an accepted delete subtracted until membership drops the row', async () => {
|
|
@@ -90,6 +90,15 @@ export interface DurableMembership {
|
|
|
90
90
|
confirmed: boolean;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Fraction of a query's TTL after which the client refreshes `lastActiveAt`.
|
|
95
|
+
*
|
|
96
|
+
* Must leave room for at least one retry: the server sweeps a view — and its
|
|
97
|
+
* `_00_list_ref` edges — the moment `lastActiveAt + ttl` passes, and the client
|
|
98
|
+
* is LIVE on those edges, so a swept-but-still-watched view empties the UI.
|
|
99
|
+
*/
|
|
100
|
+
const TTL_HEARTBEAT_FRACTION = 0.5;
|
|
101
|
+
|
|
93
102
|
export class DataModule<S extends SchemaStructure> {
|
|
94
103
|
/** Tab identity baked into mutation ids (shared-tabs rollback routing);
|
|
95
104
|
* undefined in solo mode, where mutation-id falls back to a session id. */
|
|
@@ -675,8 +684,25 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
675
684
|
* rolls the mutation back and never reports it settled, so it vanishes at
|
|
676
685
|
* once. This deadline only bounds the case where the write succeeded and its
|
|
677
686
|
* membership never arrived at all.
|
|
687
|
+
*
|
|
688
|
+
* 30 s, up from 10 s. At 10 s every server hiccup longer than a scheduler
|
|
689
|
+
* drain plus one slow database commit showed up as the user's own chat
|
|
690
|
+
* message vanishing and coming back a minute later. 30 s covers a drain and
|
|
691
|
+
* a stalled commit end to end; a membership that takes longer than that is
|
|
692
|
+
* a server fault the admin heartbeat reports, not something to paper over.
|
|
693
|
+
*/
|
|
694
|
+
private static readonly SETTLED_WRITE_GRACE_MS = 30_000;
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Whether any settled write or delete is still waiting for membership to
|
|
698
|
+
* catch up. The sync poll keeps its fast cadence while this is true, so a
|
|
699
|
+
* missed LIVE notification for the edge is picked up on the next poll tick
|
|
700
|
+
* rather than after the idle backoff.
|
|
678
701
|
*/
|
|
679
|
-
|
|
702
|
+
hasSettledWritesPending(): boolean {
|
|
703
|
+
this.pruneSettled(Date.now());
|
|
704
|
+
return this.settledWrites.size > 0 || this.settledDeletes.size > 0;
|
|
705
|
+
}
|
|
680
706
|
|
|
681
707
|
/**
|
|
682
708
|
* Report that a mutation was accepted by the server and its outbox row
|
|
@@ -2452,7 +2478,25 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
2452
2478
|
private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
|
|
2453
2479
|
if (queryState.ttlTimer) return;
|
|
2454
2480
|
|
|
2455
|
-
|
|
2481
|
+
// Half the TTL, not 0.9 of it, so ONE failed beat is survivable.
|
|
2482
|
+
//
|
|
2483
|
+
// The timer re-arms after each beat, so expiry sits a full TTL after the
|
|
2484
|
+
// last SUCCESSFUL refresh. At 0.9 a beat that fails — the SSP restarting,
|
|
2485
|
+
// a reconnect, a slow database — left 10% of the TTL before the sweep
|
|
2486
|
+
// deleted the row AND its `_00_list_ref` edges, with no second attempt in
|
|
2487
|
+
// between. On a 10m TTL that is 60 seconds of slack against an SSP
|
|
2488
|
+
// bootstrap that takes ~2 minutes on a large tenant, so the view lost a
|
|
2489
|
+
// race it could not win.
|
|
2490
|
+
//
|
|
2491
|
+
// The user-visible form is content vanishing and coming back: the client
|
|
2492
|
+
// is LIVE on those edges, so it sees the sweep's deletes immediately, and
|
|
2493
|
+
// only restores them once its heartbeat notices the row is gone and
|
|
2494
|
+
// re-registers. Reported as chat messages disappearing for 10+ seconds.
|
|
2495
|
+
//
|
|
2496
|
+
// At 0.5 a single failure still leaves another attempt and half the TTL.
|
|
2497
|
+
// The cost is one extra beat per view per TTL, which is a single
|
|
2498
|
+
// `UPDATE ... SET lastActiveAt`.
|
|
2499
|
+
const heartbeatTime = Math.floor(queryState.ttlDurationMs * TTL_HEARTBEAT_FRACTION);
|
|
2456
2500
|
|
|
2457
2501
|
queryState.ttlTimer = setTimeout(() => {
|
|
2458
2502
|
queryState.ttlTimer = null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { Sp00kySync } from './sync';
|
|
3
|
+
|
|
4
|
+
// The list_ref poll backs off toward a 5s cap on a quiet page. A settled write
|
|
5
|
+
// that is still waiting for its membership edge must hold the poll at its base
|
|
6
|
+
// cadence instead: the row is rendered on the settled-write grace alone, so
|
|
7
|
+
// the edge has to be found on the first poll after it lands, not after the
|
|
8
|
+
// backoff has grown. Otherwise a missed LIVE notification during a server
|
|
9
|
+
// stall turns into "my message vanished" even though the edge arrived.
|
|
10
|
+
|
|
11
|
+
function makeSync(pending: () => boolean) {
|
|
12
|
+
const logger: any = {
|
|
13
|
+
child: () => logger,
|
|
14
|
+
debug: () => {},
|
|
15
|
+
info: () => {},
|
|
16
|
+
warn: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
trace: () => {},
|
|
19
|
+
};
|
|
20
|
+
const remote: any = { query: vi.fn().mockResolvedValue([true]) };
|
|
21
|
+
const dataModule: any = {
|
|
22
|
+
getActiveQueryHashes: () => ['h1'],
|
|
23
|
+
hasSettledWritesPending: pending,
|
|
24
|
+
};
|
|
25
|
+
const sync = new Sp00kySync({} as any, remote, {} as any, dataModule, {} as any, logger);
|
|
26
|
+
// A poll that never observes a change: the only thing driving the streak is
|
|
27
|
+
// the settled-write signal.
|
|
28
|
+
(sync as any).pollListRefForActiveQueries = vi.fn().mockResolvedValue(false);
|
|
29
|
+
return sync;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('list_ref poll cadence while a settled write awaits membership', () => {
|
|
33
|
+
afterEach(() => vi.useRealTimers());
|
|
34
|
+
|
|
35
|
+
it('stays at the base cadence while a write is pending, then backs off', async () => {
|
|
36
|
+
vi.useFakeTimers();
|
|
37
|
+
let pending = true;
|
|
38
|
+
const sync = makeSync(() => pending);
|
|
39
|
+
const base = sync.refSyncIntervalMs;
|
|
40
|
+
|
|
41
|
+
(sync as any).startListRefPoll();
|
|
42
|
+
|
|
43
|
+
// Three quiet ticks with a write outstanding: no backoff at all.
|
|
44
|
+
for (let i = 0; i < 3; i++) {
|
|
45
|
+
await vi.advanceTimersByTimeAsync(base);
|
|
46
|
+
expect((sync as any).listRefIdleStreak).toBe(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Membership caught up: the ordinary idle backoff resumes.
|
|
50
|
+
pending = false;
|
|
51
|
+
await vi.advanceTimersByTimeAsync(base);
|
|
52
|
+
expect((sync as any).listRefIdleStreak).toBe(1);
|
|
53
|
+
await vi.advanceTimersByTimeAsync(base * 2);
|
|
54
|
+
expect((sync as any).listRefIdleStreak).toBe(2);
|
|
55
|
+
|
|
56
|
+
(sync as any).stopListRefPoll();
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -877,8 +877,12 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
877
877
|
// Reset the idle streak on any observed change so the poll snaps
|
|
878
878
|
// back to the fast base cadence; otherwise grow it so a quiet page
|
|
879
879
|
// backs off toward the cap. (`handleRemoteListRefChange` also resets
|
|
880
|
-
// it when a LIVE event lands.)
|
|
881
|
-
|
|
880
|
+
// it when a LIVE event lands.) A settled write still waiting for its
|
|
881
|
+
// membership also pins the fast cadence: the row is on borrowed time
|
|
882
|
+
// (the settled-write grace), so the edge must be found on the first
|
|
883
|
+
// poll after it lands, not after the backoff has grown to the cap.
|
|
884
|
+
const awaitingMembership = this.dataModule.hasSettledWritesPending();
|
|
885
|
+
this.listRefIdleStreak = changed || awaitingMembership ? 0 : this.listRefIdleStreak + 1;
|
|
882
886
|
const next = listRefPollDelayMs({
|
|
883
887
|
idleStreak: this.listRefIdleStreak,
|
|
884
888
|
baseIntervalMs: this.refSyncIntervalMs,
|