@spooky-sync/core 0.0.1-canary.286 → 0.0.1-canary.288
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/AGENTS.md +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +272 -46
- package/dist/live.js +1 -1
- package/dist/pure.d.ts +9 -2
- package/dist/pure.js +2 -2
- package/dist/worker-edges.js +10 -2
- package/package.json +3 -3
- package/src/boot/boot.saga.test.ts +9 -2
- package/src/boot/boot.saga.ts +4 -1
- package/src/boot/bucket-switch.saga.ts +2 -0
- package/src/client/runtime.test.ts +4 -1
- package/src/client/sp00ky-client.test.ts +8 -1
- package/src/kernel/constants.ts +15 -2
- package/src/query/lifecycle.saga.test.ts +169 -17
- package/src/query/lifecycle.saga.ts +86 -28
- package/src/query/materialize.saga.test.ts +36 -3
- package/src/query/materialize.saga.ts +15 -6
- package/src/query/materialize.test.ts +9 -1
- package/src/query/materialize.ts +14 -1
- package/src/query/membership.saga.test.ts +30 -2
- package/src/query/membership.saga.ts +18 -2
- package/src/query/membership.ts +30 -0
- package/src/query/render-set.test.ts +12 -1
- package/src/query/render-set.ts +11 -0
- package/src/query/sql.test.ts +2 -1
- package/src/query/sql.ts +15 -1
- package/src/services/tabs/coordinator.test.ts +21 -0
- package/src/services/tabs/coordinator.ts +5 -0
- package/src/state/client-state.ts +7 -0
- package/src/state/reducers.test.ts +19 -0
- package/src/state/reducers.ts +41 -3
- package/src/state/selectors.test.ts +18 -0
- package/src/state/selectors.ts +32 -0
- package/src/sync/tabs.saga.test.ts +4 -0
- package/src/sync/tabs.saga.ts +3 -0
package/src/boot/boot.saga.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { ANON_USER_ID } from '../modules/ref-tables';
|
|
|
6
6
|
import type { SagaEnv } from '../query/env';
|
|
7
7
|
import * as sql from '../query/sql';
|
|
8
8
|
import { loadOutbox } from '../mutation/push.saga';
|
|
9
|
+
import { loadViews } from '../query/lifecycle.saga';
|
|
10
|
+
import { GC_BOOT_DELAY_MS } from '../kernel/constants';
|
|
9
11
|
|
|
10
12
|
export interface BootOptions {
|
|
11
13
|
/** A shared-tabs coordinator is available: ask it for a role instead of opening the store alone. */
|
|
@@ -34,6 +36,7 @@ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
|
|
|
34
36
|
yield fx.state.update(R.setIdentity({ bucketId: bucket }));
|
|
35
37
|
if ((yield fx.service('local.usesSurqlSchema')) as boolean) yield fx.service('migrator.provision');
|
|
36
38
|
yield* migrateWindowToView();
|
|
39
|
+
yield* loadViews();
|
|
37
40
|
yield fx.dispatch({ type: 'WarmBlobs' });
|
|
38
41
|
yield fx.service('ssp.init');
|
|
39
42
|
yield fx.service('ssp.setPermissions');
|
|
@@ -51,7 +54,7 @@ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
|
|
|
51
54
|
yield fx.service('features.init');
|
|
52
55
|
yield fx.service('releases.init');
|
|
53
56
|
yield fx.dispatch({ type: 'LifecycleTick' });
|
|
54
|
-
yield fx.
|
|
57
|
+
yield fx.timer.set('gc', GC_BOOT_DELAY_MS, { type: 'GcTick' });
|
|
55
58
|
yield fx.state.update(R.setIdentity({ localReady: true }));
|
|
56
59
|
yield fx.dispatch({ type: 'StartRemote' });
|
|
57
60
|
}
|
|
@@ -10,6 +10,7 @@ import type { SagaEnv } from '../query/env';
|
|
|
10
10
|
import { queryHashInput } from '../query/hash';
|
|
11
11
|
import { isResolvedBefore, parseViewRow } from '../query/membership';
|
|
12
12
|
import * as sql from '../query/sql';
|
|
13
|
+
import { loadViews } from '../query/lifecycle.saga';
|
|
13
14
|
import { loadOutbox } from '../mutation/push.saga';
|
|
14
15
|
|
|
15
16
|
export interface BucketSwitchOptions {
|
|
@@ -71,6 +72,7 @@ export function* bucketSwitch(env: SagaEnv, target: string, release: (() => void
|
|
|
71
72
|
yield fx.emit({ type: 'log', level: 'warn', message: 'failed to re-persist the auth token', data: { error } });
|
|
72
73
|
}
|
|
73
74
|
}
|
|
75
|
+
yield* loadViews();
|
|
74
76
|
yield* rebindQueries();
|
|
75
77
|
yield* loadOutbox(env);
|
|
76
78
|
yield fx.dispatch({ type: 'EnsureRegistered' });
|
|
@@ -73,7 +73,10 @@ describe('Runtime lanes', () => {
|
|
|
73
73
|
|
|
74
74
|
describe('Runtime state hooks', () => {
|
|
75
75
|
it('dirty queries are materialized after the debounce, once per burst', async () => {
|
|
76
|
-
const { a, rt } = make(
|
|
76
|
+
const { a, rt } = make(
|
|
77
|
+
{ local: { select: async () => [{ id: 'thing:1' }] } },
|
|
78
|
+
buildState([buildEntry({ def: { hash: 'q', plan: { table: 'thing' } as any } })], R.reloadViews(new Map([['v', ['thing:1']]])))
|
|
79
|
+
);
|
|
77
80
|
const seen: unknown[] = [];
|
|
78
81
|
rt.subscribe('q', (rows) => seen.push(rows));
|
|
79
82
|
rt.update(R.markDirty(['q']));
|
|
@@ -49,7 +49,14 @@ describe('Sp00kyClient facade', () => {
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
it('queryRaw registers locally and returns the hash before any remote work; subscribers paint from the local store', async () => {
|
|
52
|
-
|
|
52
|
+
// Boot loads the view index: `thing:1` is vouched for, `thing:2` is an orphan and stays hidden.
|
|
53
|
+
const stored = (sql: string) =>
|
|
54
|
+
sql.startsWith('SELECT * FROM thing')
|
|
55
|
+
? [[{ id: 'thing:1' }, { id: 'thing:2' }]]
|
|
56
|
+
: sql === 'SELECT * FROM _00_view'
|
|
57
|
+
? [[{ id: '_00_view:k', ids: [['thing:1', 1]] }]]
|
|
58
|
+
: [];
|
|
59
|
+
const { client, a } = makeClient({ local: { query: (async (sql: string) => stored(sql)) as any } });
|
|
53
60
|
await client.init();
|
|
54
61
|
const hash = await client.queryRaw('SELECT * FROM thing', {}, '10m');
|
|
55
62
|
expect(client.state.queries.get(hash)!.lifecycle.phase).toBe('cold');
|
package/src/kernel/constants.ts
CHANGED
|
@@ -48,8 +48,21 @@ export const AUTH_READY_RETRY_MS = 500;
|
|
|
48
48
|
export const AUTH_READY_MAX_ATTEMPTS = 10;
|
|
49
49
|
/** Heartbeat at this fraction of the shortest ttl in state. */
|
|
50
50
|
export const TTL_HEARTBEAT_FRACTION = 0.5;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Orphan body garbage collection: the first sweep waits this long after boot
|
|
53
|
+
* (off the first paint and the first sync round), then repeats on the interval.
|
|
54
|
+
*/
|
|
55
|
+
export const GC_BOOT_DELAY_MS = 30_000;
|
|
56
|
+
export const GC_INTERVAL_MS = 60 * 60 * 1000;
|
|
57
|
+
/** Ids deleted per GC step. */
|
|
58
|
+
export const GC_CHUNK = 200;
|
|
59
|
+
/**
|
|
60
|
+
* A `_00_view` row no query has resolved for this long stops vouching for its
|
|
61
|
+
* ids (the sweep deletes it, so its bodies can be collected). Each session
|
|
62
|
+
* that resolves a query rewrites its row, so this only retires query shapes
|
|
63
|
+
* the app no longer runs.
|
|
64
|
+
*/
|
|
65
|
+
export const VIEW_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
53
66
|
/** Remote adapter slot limit. */
|
|
54
67
|
export const REMOTE_CONCURRENCY = 16;
|
|
55
68
|
/** Rolling telemetry sample window per query. */
|
|
@@ -4,7 +4,7 @@ import { runPure } from '../testing/run-pure';
|
|
|
4
4
|
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
5
5
|
import * as R from '../state/reducers';
|
|
6
6
|
import { defaultEnv } from './env';
|
|
7
|
-
import { ackPrune, evictQuery, gcTick, lifecycleTick } from './lifecycle.saga';
|
|
7
|
+
import { ackPrune, evictQuery, gcTick, lifecycleTick, loadViews } from './lifecycle.saga';
|
|
8
8
|
import type { StatementResult } from '../kernel/effects';
|
|
9
9
|
|
|
10
10
|
const env = defaultEnv({ tables: [] } as any);
|
|
@@ -75,31 +75,158 @@ describe('ackPrune', () => {
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
describe('loadViews', () => {
|
|
79
|
+
it('fills the index from every _00_view row (members and children); a failed read leaves it empty', async () => {
|
|
80
|
+
const rows = [
|
|
81
|
+
{ id: '_00_view:a', ids: [['t:1', 1]], children: [['c:1', 1], 'junk'] },
|
|
82
|
+
{ id: new RecordId('_00_view', 'b'), ids: [] },
|
|
83
|
+
{ id: '', ids: [] },
|
|
84
|
+
{ ids: [['t:no-key', 1]] },
|
|
85
|
+
null,
|
|
86
|
+
];
|
|
87
|
+
const out = await runPure(loadViews(), { state: buildState(), handlers: { 'local.query': () => [rows] } });
|
|
88
|
+
expect(out.state.views).toEqual(new Map([['a', ['t:1', 'c:1']], ['b', []]]));
|
|
89
|
+
const odd = await runPure(loadViews(), { state: buildState(), handlers: { 'local.query': () => undefined } });
|
|
90
|
+
expect(odd.state.views.size).toBe(0);
|
|
91
|
+
const failed = await runPure(loadViews(), {
|
|
92
|
+
state: buildState(),
|
|
93
|
+
handlers: {
|
|
94
|
+
'local.query': () => {
|
|
95
|
+
throw new Error('no table');
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
expect(failed.state.views.size).toBe(0);
|
|
100
|
+
expect(failed.emitted).toEqual([expect.objectContaining({ level: 'warn', message: 'view index read failed' })]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
78
104
|
describe('gcTick', () => {
|
|
79
|
-
|
|
105
|
+
const day = 24 * 60 * 60 * 1000;
|
|
106
|
+
const now = 100 * day;
|
|
107
|
+
const ready = R.setIdentity({ primed: true, bucketId: 'u1' });
|
|
108
|
+
const deletedIds = (log: Array<{ kind: string }>) =>
|
|
109
|
+
log.filter((e: any) => e.kind === 'local.delete').map((e: any) => (e.table === '_00_view' ? `view:${e.id.id}` : e.id));
|
|
110
|
+
|
|
111
|
+
it('retires stale view rows, deletes the bodies nothing retains from the store and the circuit, re-fetches, reschedules', async () => {
|
|
80
112
|
const s = buildState(
|
|
81
|
-
[],
|
|
82
|
-
|
|
113
|
+
[buildEntry({ def: { hash: 'h', viewKey: 'held' }, lifecycle: { phase: 'cached' }, remoteArray: [['thing:mem', 1]] })],
|
|
114
|
+
ready,
|
|
115
|
+
R.setVersions(
|
|
116
|
+
['thing:keep', 'child:1', 'thing:stale', 'thing:held', 'thing:stuck', 'thing:mem', 'thing:gone', 'thing:pending', '_00_view:x', 'thing:failed'].map(
|
|
117
|
+
(id) => [id, 1] as const
|
|
118
|
+
)
|
|
119
|
+
),
|
|
83
120
|
R.outboxReplace([buildOutboxItem({ recordId: 'thing:pending' })])
|
|
84
121
|
);
|
|
85
|
-
const
|
|
122
|
+
const rows = [
|
|
123
|
+
{ id: '_00_view:fresh', ids: [['thing:keep', 1]], children: [['child:1', 1]], updatedAt: now - day },
|
|
124
|
+
// Nobody resolved this query for 15 days: the row is retired and stops vouching.
|
|
125
|
+
{ id: new RecordId('_00_view', 'old'), ids: [['thing:stale', 1]], updatedAt: now - 15 * day },
|
|
126
|
+
// As old, but a query in state holds it.
|
|
127
|
+
{ id: '_00_view:held', ids: [['thing:held', 1]] },
|
|
128
|
+
// As old, but its delete fails: it keeps vouching.
|
|
129
|
+
{ id: '_00_view:stuck', ids: [['thing:stuck', 1]], updatedAt: 0 },
|
|
130
|
+
{ ids: 'bad' },
|
|
131
|
+
];
|
|
86
132
|
const out = await runPure(gcTick(), {
|
|
87
133
|
state: s,
|
|
134
|
+
now,
|
|
88
135
|
handlers: {
|
|
89
|
-
'local.query': () =>
|
|
136
|
+
'local.query': (e: any) => {
|
|
137
|
+
expect(e.sql).toBe('SELECT * FROM _00_view');
|
|
138
|
+
return [rows];
|
|
139
|
+
},
|
|
90
140
|
'local.delete': (e: any) => {
|
|
91
|
-
|
|
92
|
-
if (e.id === 'thing:failed') throw new Error('busy');
|
|
141
|
+
if (e.id === 'thing:failed' || e.id?.id === 'stuck') throw new Error('busy');
|
|
93
142
|
},
|
|
94
143
|
'ssp.ingest': () => undefined,
|
|
95
144
|
},
|
|
96
145
|
});
|
|
97
|
-
expect(
|
|
98
|
-
expect(
|
|
146
|
+
expect(deletedIds(out.log)).toEqual(['view:old', 'view:stuck', 'thing:stale', 'thing:gone', 'thing:failed']);
|
|
147
|
+
expect(out.state.views).toEqual(
|
|
148
|
+
new Map([
|
|
149
|
+
['fresh', ['thing:keep', 'child:1']],
|
|
150
|
+
['held', ['thing:held']],
|
|
151
|
+
['stuck', ['thing:stuck']],
|
|
152
|
+
])
|
|
153
|
+
);
|
|
154
|
+
expect([...out.state.versions.keys()]).toEqual(['thing:keep', 'child:1', 'thing:held', 'thing:stuck', 'thing:mem', 'thing:pending', '_00_view:x', 'thing:failed']);
|
|
99
155
|
const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
|
|
100
|
-
expect(ingest.records).toEqual([
|
|
101
|
-
|
|
102
|
-
|
|
156
|
+
expect(ingest.records).toEqual([
|
|
157
|
+
{ table: 'thing', op: 'DELETE', id: 'thing:stale', record: {} },
|
|
158
|
+
{ table: 'thing', op: 'DELETE', id: 'thing:gone', record: {} },
|
|
159
|
+
]);
|
|
160
|
+
// Solo: nobody to relay to.
|
|
161
|
+
expect(out.emitted.filter((e) => e.type === 'tabs:broadcast')).toEqual([]);
|
|
162
|
+
expect(out.emitted).toContainEqual(expect.objectContaining({ message: 'orphan gc done', data: { removed: 2, retiredViews: 1 } }));
|
|
163
|
+
expect(out.dispatched).toEqual([{ type: 'FetchRows' }]);
|
|
164
|
+
expect(out.timers.get('gc')).toEqual({ ms: 60 * 60 * 1000, event: { type: 'GcTick' } });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('waits for the circuit prime (the versions); a leader relays its deletes to the other tabs', async () => {
|
|
168
|
+
const s = buildState([], R.setIdentity({ bucketId: 'u1' }), R.setTabRole('leader'), R.setVersions([['thing:gone', 1]]));
|
|
169
|
+
const out = await runPure(gcTick(), {
|
|
170
|
+
state: s,
|
|
171
|
+
handlers: {
|
|
172
|
+
'state.wait': (e: any, ctx) => {
|
|
173
|
+
expect(e.until(ctx.state)).toBe(false);
|
|
174
|
+
ctx.state = R.setIdentity({ primed: true })(ctx.state);
|
|
175
|
+
},
|
|
176
|
+
'local.query': () => [[]],
|
|
177
|
+
'local.delete': () => undefined,
|
|
178
|
+
'ssp.ingest': () => undefined,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
expect(out.emitted).toContainEqual({
|
|
182
|
+
type: 'tabs:broadcast',
|
|
183
|
+
message: { type: 'ingest', records: [{ table: 'thing', op: 'DELETE', id: 'thing:gone', record: {} }] },
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('a follower only refreshes its index: no view row or body is deleted', async () => {
|
|
188
|
+
const s = buildState([], ready, R.setTabRole('follower'), R.setVersions([['thing:gone', 1]]));
|
|
189
|
+
const out = await runPure(gcTick(), {
|
|
190
|
+
state: s,
|
|
191
|
+
now,
|
|
192
|
+
handlers: { 'local.query': () => [[{ id: '_00_view:old', ids: [['thing:x', 1]], updatedAt: 0 }]] },
|
|
193
|
+
});
|
|
194
|
+
expect(out.state.views).toEqual(new Map([['old', ['thing:x']]]));
|
|
195
|
+
expect(deletedIds(out.log)).toEqual([]);
|
|
196
|
+
expect(out.state.versions.has('thing:gone')).toBe(true);
|
|
197
|
+
expect(out.timers.has('gc')).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('each chunk is re-checked against fresh state, and the sweep stops when the bucket moves', async () => {
|
|
201
|
+
const ids = Array.from({ length: 250 }, (_, i) => `thing:o${i}`);
|
|
202
|
+
const s = buildState([], ready, R.setVersions(ids.map((id) => [id, 1] as const)));
|
|
203
|
+
const handlers = (onFirst: (ctx: any) => void) => ({
|
|
204
|
+
'local.query': () => [[]],
|
|
205
|
+
'local.delete': (e: any, ctx: any) => {
|
|
206
|
+
if (e.id === 'thing:o0') onFirst(ctx);
|
|
207
|
+
},
|
|
208
|
+
'ssp.ingest': () => undefined,
|
|
209
|
+
});
|
|
210
|
+
// Meanwhile a query commits o210 and o220's body goes another way: neither is deleted.
|
|
211
|
+
const recheck = await runPure(gcTick(), {
|
|
212
|
+
state: s,
|
|
213
|
+
handlers: handlers((ctx) => {
|
|
214
|
+
ctx.state = R.compose(R.putQuery(buildEntry({ def: { hash: 'q' }, remoteArray: [['thing:o210', 1]] })), R.deleteVersions(['thing:o220']))(ctx.state);
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
const removed = deletedIds(recheck.log);
|
|
218
|
+
expect(removed).toHaveLength(248);
|
|
219
|
+
expect(removed).not.toContain('thing:o210');
|
|
220
|
+
expect(removed).not.toContain('thing:o220');
|
|
221
|
+
expect(recheck.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(2);
|
|
222
|
+
const moved = await runPure(gcTick(), { state: s, handlers: handlers((ctx) => (ctx.state = R.setIdentity({ bucketId: 'u2' })(ctx.state))) });
|
|
223
|
+
expect(deletedIds(moved.log)).toHaveLength(200);
|
|
224
|
+
expect(moved.timers.has('gc')).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('failures are logged and the tick still reschedules; a sweep that removes nothing asks for nothing', async () => {
|
|
228
|
+
const s = buildState([], ready, R.setVersions([['thing:gone', 1]]));
|
|
229
|
+
const readFails = await runPure(gcTick(), {
|
|
103
230
|
state: s,
|
|
104
231
|
handlers: {
|
|
105
232
|
'local.query': () => {
|
|
@@ -107,10 +234,35 @@ describe('gcTick', () => {
|
|
|
107
234
|
},
|
|
108
235
|
},
|
|
109
236
|
});
|
|
110
|
-
expect(
|
|
111
|
-
expect(
|
|
112
|
-
const
|
|
113
|
-
|
|
237
|
+
expect(readFails.emitted).toEqual([expect.objectContaining({ message: 'orphan gc failed' })]);
|
|
238
|
+
expect(readFails.timers.has('gc')).toBe(true);
|
|
239
|
+
const ingestFails = await runPure(gcTick(), {
|
|
240
|
+
state: s,
|
|
241
|
+
handlers: {
|
|
242
|
+
'local.query': () => [[]],
|
|
243
|
+
'local.delete': () => undefined,
|
|
244
|
+
'ssp.ingest': () => {
|
|
245
|
+
throw new Error('wasm');
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
expect(ingestFails.emitted).toEqual([expect.objectContaining({ message: 'orphan gc failed' })]);
|
|
250
|
+
expect(ingestFails.timers.has('gc')).toBe(true);
|
|
251
|
+
const deleteFails = await runPure(gcTick(), {
|
|
252
|
+
state: s,
|
|
253
|
+
handlers: {
|
|
254
|
+
'local.query': () => [[]],
|
|
255
|
+
'local.delete': () => {
|
|
256
|
+
throw new Error('busy');
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
expect(deleteFails.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(0);
|
|
261
|
+
expect(deleteFails.dispatched).toEqual([]);
|
|
262
|
+
expect(deleteFails.emitted).toContainEqual(expect.objectContaining({ message: 'orphan gc done', data: { removed: 0, retiredViews: 0 } }));
|
|
263
|
+
const nothing = await runPure(gcTick(), { state: buildState([], ready), handlers: { 'local.query': () => [] } });
|
|
264
|
+
expect(nothing.log.filter((e) => e.kind === 'local.delete')).toHaveLength(0);
|
|
265
|
+
expect(nothing.dispatched).toEqual([]);
|
|
114
266
|
});
|
|
115
267
|
});
|
|
116
268
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { Saga } from '../kernel/saga';
|
|
2
2
|
import type { Settled, StatementResult } from '../kernel/effects';
|
|
3
3
|
import { fx } from '../kernel/effects';
|
|
4
|
-
import { ACK_GRACE_MS, GC_INTERVAL_MS, TTL_HEARTBEAT_FRACTION } from '../kernel/constants';
|
|
5
|
-
import type {
|
|
4
|
+
import { ACK_GRACE_MS, GC_CHUNK, GC_INTERVAL_MS, TTL_HEARTBEAT_FRACTION, VIEW_RETENTION_MS } from '../kernel/constants';
|
|
5
|
+
import type { IngestRecord } from '../services/stream-processor/index';
|
|
6
|
+
import type { ClientState, QueryEntry, TabRole } from '../state/client-state';
|
|
6
7
|
import * as R from '../state/reducers';
|
|
7
|
-
import { evictable, shortestTtlMs } from '../state/selectors';
|
|
8
|
+
import { evictable, retained, shortestTtlMs } from '../state/selectors';
|
|
8
9
|
import type { SagaEnv } from './env';
|
|
10
|
+
import { parseViewIndex, type ViewIndexRow } from './membership';
|
|
9
11
|
import * as sql from './sql';
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -65,37 +67,93 @@ export function* ackPrune(): Saga<void> {
|
|
|
65
67
|
if (stillAcked) yield fx.timer.set('ack-prune', ACK_GRACE_MS, { type: 'AckPrune' });
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/** Every `_00_view` row, parsed. Throws when the read fails. */
|
|
71
|
+
function* readViews(): Saga<ViewIndexRow[]> {
|
|
72
|
+
const res = (yield fx.local.query(sql.readViewRows())) as unknown[];
|
|
73
|
+
return parseViewIndex(Array.isArray(res) ? res[0] : undefined);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const indexOf = (rows: ViewIndexRow[]): Map<string, string[]> => new Map(rows.map((r) => [r.key, r.ids]));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load the durable view index (boot, bucket switch). A failed read leaves it
|
|
80
|
+
* empty, which fails closed: cold scans paint nothing until the server answers.
|
|
81
|
+
*/
|
|
82
|
+
export function* loadViews(): Saga<void> {
|
|
83
|
+
try {
|
|
84
|
+
yield fx.state.update(R.reloadViews(indexOf(yield* readViews())));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'view index read failed', data: { error } });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tableOf = (id: string): string => id.slice(0, id.indexOf(':'));
|
|
91
|
+
|
|
68
92
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
93
|
+
* Orphan collection, `GC_BOOT_DELAY_MS` after boot and then every
|
|
94
|
+
* `GC_INTERVAL_MS`. Re-reads every `_00_view` row into the index, retires the
|
|
95
|
+
* rows no query has resolved for `VIEW_RETENTION_MS`, then deletes the bodies
|
|
96
|
+
* nothing retains (see `retained`) from the store and the circuit, a chunk at
|
|
97
|
+
* a time. Only a leader or solo tab deletes, and a leader relays the deletes
|
|
98
|
+
* so followers drop the rows too; a follower only refreshes its index.
|
|
71
99
|
*/
|
|
72
100
|
export function* gcTick(): Saga<void> {
|
|
101
|
+
yield fx.state.wait((s) => s.primed);
|
|
73
102
|
try {
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
103
|
+
const rows = yield* readViews();
|
|
104
|
+
const [role, bucket, held] = (yield fx.state.read((s) => [
|
|
105
|
+
s.tabRole,
|
|
106
|
+
s.bucketId,
|
|
107
|
+
new Set([...s.queries.values()].map((e) => e.def.viewKey)),
|
|
108
|
+
])) as [TabRole, string | null, Set<string>];
|
|
109
|
+
const now = (yield fx.now()) as number;
|
|
110
|
+
const expired = role === 'follower' ? [] : rows.filter((r) => !held.has(r.key) && now - r.updatedAt >= VIEW_RETENTION_MS);
|
|
111
|
+
const retired = new Set<string>();
|
|
112
|
+
for (let i = 0; i < expired.length; i += GC_CHUNK) {
|
|
113
|
+
const chunk = expired.slice(i, i + GC_CHUNK);
|
|
114
|
+
const settled = (yield fx.all(chunk.map((r) => fx.local.delete(sql.VIEW_TABLE, sql.viewRecordId(r.key))))) as Settled[];
|
|
115
|
+
chunk.forEach((r, j) => settled[j].ok && retired.add(r.key));
|
|
80
116
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
const table = id.slice(0, id.indexOf(':'));
|
|
86
|
-
return fx.local.delete(table, id);
|
|
87
|
-
});
|
|
88
|
-
for (let i = 0; i < deletes.length; i += 200) {
|
|
89
|
-
const settled = (yield fx.all(deletes.slice(i, i + 200))) as Settled[];
|
|
90
|
-
const done = orphans.slice(i, i + 200).filter((_, j) => settled[j].ok);
|
|
91
|
-
yield fx.state.update(R.deleteVersions(done));
|
|
92
|
-
if (done.length > 0) {
|
|
93
|
-
yield fx.ssp.ingest(done.map((id) => ({ table: id.slice(0, id.indexOf(':')), op: 'DELETE', id, record: {} })));
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
yield fx.emit({ type: 'log', level: 'info', message: 'orphan gc done', data: { removed: orphans.length } });
|
|
117
|
+
yield fx.state.update(R.reloadViews(indexOf(rows.filter((r) => !retired.has(r.key)))));
|
|
118
|
+
if (role === 'follower') return;
|
|
119
|
+
const removed = yield* collectOrphans(bucket, role === 'leader');
|
|
120
|
+
yield fx.emit({ type: 'log', level: 'info', message: 'orphan gc done', data: { removed, retiredViews: retired.size } });
|
|
97
121
|
} catch (error) {
|
|
98
122
|
yield fx.emit({ type: 'log', level: 'warn', message: 'orphan gc failed', data: { error } });
|
|
123
|
+
} finally {
|
|
124
|
+
yield fx.timer.set('gc', GC_INTERVAL_MS, { type: 'GcTick' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Delete every body nothing retains, `GC_CHUNK` at a time. Each chunk is
|
|
130
|
+
* re-checked against fresh state (a membership committed meanwhile may name
|
|
131
|
+
* an id again) and the sweep stops if the bucket moved under it.
|
|
132
|
+
*/
|
|
133
|
+
function* collectOrphans(bucket: string | null, relay: boolean): Saga<number> {
|
|
134
|
+
const candidates = (yield fx.state.read((s) => {
|
|
135
|
+
const keep = retained(s);
|
|
136
|
+
return [...s.versions.keys()].filter((id) => !keep(id));
|
|
137
|
+
})) as string[];
|
|
138
|
+
let removed = 0;
|
|
139
|
+
for (let i = 0; i < candidates.length; i += GC_CHUNK) {
|
|
140
|
+
const chunk = (yield fx.state.read((s) => {
|
|
141
|
+
if (!s.primed || s.bucketId !== bucket) return null;
|
|
142
|
+
const keep = retained(s);
|
|
143
|
+
return candidates.slice(i, i + GC_CHUNK).filter((id) => s.versions.has(id) && !keep(id));
|
|
144
|
+
})) as string[] | null;
|
|
145
|
+
if (chunk === null) break;
|
|
146
|
+
const settled = (yield fx.all(chunk.map((id) => fx.local.delete(tableOf(id), id)))) as Settled[];
|
|
147
|
+
const done = chunk.filter((_, j) => settled[j].ok);
|
|
148
|
+
if (done.length === 0) continue;
|
|
149
|
+
const records: IngestRecord[] = done.map((id) => ({ table: tableOf(id), op: 'DELETE', id, record: {} }));
|
|
150
|
+
yield fx.state.update(R.deleteVersions(done));
|
|
151
|
+
yield fx.ssp.ingest(records);
|
|
152
|
+
if (relay) yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records } });
|
|
153
|
+
removed += done.length;
|
|
99
154
|
}
|
|
100
|
-
|
|
155
|
+
// A query may have committed a deleted id between its chunk's check and the
|
|
156
|
+
// delete; with its version gone, the fetch plan pulls it back.
|
|
157
|
+
if (removed > 0) yield fx.dispatch({ type: 'FetchRows' });
|
|
158
|
+
return removed;
|
|
101
159
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
2
3
|
import { runPure } from '../testing/run-pure';
|
|
3
4
|
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
4
5
|
import * as R from '../state/reducers';
|
|
@@ -10,8 +11,9 @@ describe('materialize', () => {
|
|
|
10
11
|
const out = await runPure(materialize('zz'), { state: buildState() });
|
|
11
12
|
expect(out.log.filter((e) => e.kind !== 'state.read')).toHaveLength(0);
|
|
12
13
|
});
|
|
14
|
+
const vouch = (...ids: string[]) => R.reloadViews(new Map([['other-view', ids]]));
|
|
13
15
|
it('cold: predicate scan through the plan; rows land, dirt clears, subscribers hear once', async () => {
|
|
14
|
-
const s = R.markDirty(['a'])(buildState([buildEntry({ def: { hash: 'a', plan } })]));
|
|
16
|
+
const s = R.markDirty(['a'])(buildState([buildEntry({ def: { hash: 'a', plan } })], vouch('thing:1')));
|
|
15
17
|
const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': (e: any) => (e.plan.ids ? [] : [{ id: 'thing:1' }]) } });
|
|
16
18
|
const en = out.state.queries.get('a')!;
|
|
17
19
|
expect(en.records).toEqual([{ id: 'thing:1' }]);
|
|
@@ -24,8 +26,39 @@ describe('materialize', () => {
|
|
|
24
26
|
expect(again.emitted).toEqual([]);
|
|
25
27
|
expect(again.state.queries.get('a')!.telemetry.updateCount).toBe(1);
|
|
26
28
|
});
|
|
27
|
-
it('cold
|
|
28
|
-
const
|
|
29
|
+
it('cold: a body no view vouches for (an orphan) does not paint; vouched, optimistic, _00_ and id-less rows do', async () => {
|
|
30
|
+
const s = buildState(
|
|
31
|
+
[buildEntry({ def: { hash: 'a', plan } })],
|
|
32
|
+
vouch('thing:1', 'thing:4'),
|
|
33
|
+
R.outboxReplace([buildOutboxItem({ id: 'c', type: 'create', recordId: 'thing:3' })])
|
|
34
|
+
);
|
|
35
|
+
const scanned = [
|
|
36
|
+
{ id: 'thing:1' },
|
|
37
|
+
{ id: 'thing:2' },
|
|
38
|
+
{ id: 'thing:3' },
|
|
39
|
+
{ id: '_00_user_feature:f' },
|
|
40
|
+
{ name: 'no id' },
|
|
41
|
+
{ id: new RecordId('thing', '4') },
|
|
42
|
+
{ id: new RecordId('thing', '5') },
|
|
43
|
+
];
|
|
44
|
+
const out = await runPure(materialize('a'), { state: s, handlers: { 'local.select': () => scanned } });
|
|
45
|
+
expect(out.state.queries.get('a')!.records).toEqual([
|
|
46
|
+
{ id: 'thing:1' },
|
|
47
|
+
{ id: 'thing:3' },
|
|
48
|
+
{ id: '_00_user_feature:f' },
|
|
49
|
+
{ name: 'no id' },
|
|
50
|
+
{ id: new RecordId('thing', '4') },
|
|
51
|
+
]);
|
|
52
|
+
// Once the server has answered, membership decides and the index is not consulted.
|
|
53
|
+
const live = buildState([buildEntry({ def: { hash: 'a', plan }, lifecycle: { phase: 'live' }, remoteArray: [['thing:2', 1]] })]);
|
|
54
|
+
const l = await runPure(materialize('a'), { state: live, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
|
|
55
|
+
expect(l.state.queries.get('a')!.records).toEqual([{ id: 'thing:2' }]);
|
|
56
|
+
});
|
|
57
|
+
it('cold window renders the SSP local window (orphans dropped); live renders membership with the overlay', async () => {
|
|
58
|
+
const win = buildState(
|
|
59
|
+
[buildEntry({ def: { hash: 'w', plan: { ...plan, offset: 5 }, surql: 'SELECT * FROM thing LIMIT 5 START 5' }, localArray: [['thing:7', 1], ['thing:8', 1]] })],
|
|
60
|
+
vouch('thing:7')
|
|
61
|
+
);
|
|
29
62
|
const w = await runPure(materialize('w'), { state: win, handlers: { 'local.select': (e: any) => e.plan.ids.map((id: any) => ({ id: `${id.table}:${id.id}` })) } });
|
|
30
63
|
expect(w.state.queries.get('w')!.records).toEqual([{ id: 'thing:7' }]);
|
|
31
64
|
const live = buildState(
|
|
@@ -4,26 +4,34 @@ import type { Saga } from '../kernel/saga';
|
|
|
4
4
|
import { fx } from '../kernel/effects';
|
|
5
5
|
import type { QueryEntry } from '../state/client-state';
|
|
6
6
|
import * as R from '../state/reducers';
|
|
7
|
-
import { overlay } from '../state/selectors';
|
|
7
|
+
import { overlay, vouched } from '../state/selectors';
|
|
8
8
|
import type { Overlay } from '../state/selectors';
|
|
9
|
-
import { isWindowed, materializeEffect, rowsEqual, rowsFromResult } from './materialize';
|
|
10
|
-
import { buildRenderIds, resolveMembership } from './render-set';
|
|
9
|
+
import { isWindowed, materializeEffect, rowKey, rowsEqual, rowsFromResult } from './materialize';
|
|
10
|
+
import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
|
|
11
|
+
|
|
12
|
+
const NONE: ReadonlySet<string> = new Set();
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Render one query from state: membership (or a cold scan), the outbox
|
|
14
16
|
* overlay, one local read, then notify subscribers if the rows changed. The
|
|
15
17
|
* only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
|
|
16
|
-
* query is dirty.
|
|
18
|
+
* query is dirty. Before the server has answered (`cold`) only bodies some
|
|
19
|
+
* durable view vouches for paint (see `paintsCold`).
|
|
17
20
|
*/
|
|
18
21
|
export function* materialize(hash: QueryHash): Saga<void> {
|
|
19
|
-
const [entry, ov] = (yield fx.state.read((s) =>
|
|
22
|
+
const [entry, ov, known] = (yield fx.state.read((s) => {
|
|
23
|
+
const e = s.queries.get(hash);
|
|
24
|
+
return [e, overlay(s), e?.lifecycle.phase === 'cold' ? vouched(s) : NONE];
|
|
25
|
+
})) as [QueryEntry | undefined, Overlay, ReadonlySet<string>];
|
|
20
26
|
if (!entry) return;
|
|
21
27
|
const t0 = (yield fx.now()) as number;
|
|
22
28
|
const isWindow = isWindowed(entry.def.surql);
|
|
29
|
+
const paints = (id: string | null) => id === null || paintsCold(id, known, ov);
|
|
30
|
+
const coldWindow = entry.lifecycle.phase === 'cold' && isWindow;
|
|
23
31
|
const membership = resolveMembership({
|
|
24
32
|
phase: entry.lifecycle.phase,
|
|
25
33
|
remoteArray: entry.remoteArray,
|
|
26
|
-
localArray: entry.localArray,
|
|
34
|
+
localArray: coldWindow ? entry.localArray.filter(([id]) => paints(id)) : entry.localArray,
|
|
27
35
|
isWindow,
|
|
28
36
|
});
|
|
29
37
|
const ids = membership
|
|
@@ -36,6 +44,7 @@ export function* materialize(hash: QueryHash): Saga<void> {
|
|
|
36
44
|
let rows: Record<string, unknown>[];
|
|
37
45
|
try {
|
|
38
46
|
rows = rowsFromResult(effect, yield effect);
|
|
47
|
+
if (ids === null) rows = rows.filter((row) => paints(rowKey(row)));
|
|
39
48
|
} catch (error) {
|
|
40
49
|
yield fx.state.update(R.compose(R.recordError(hash), R.clearDirty(hash)));
|
|
41
50
|
yield fx.emit({ type: 'log', level: 'warn', message: 'materialize failed', data: { hash, error } });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { RecordId } from 'surrealdb';
|
|
3
|
-
import { isWindowed, materializeEffect, rowsEqual, rowsFromResult } from './materialize';
|
|
3
|
+
import { isWindowed, materializeEffect, rowKey, rowsEqual, rowsFromResult } from './materialize';
|
|
4
4
|
|
|
5
5
|
describe('materializeEffect', () => {
|
|
6
6
|
const plan = { table: 'thing', where: [{ field: 'a', op: '=', value: 1 }] } as any;
|
|
@@ -48,3 +48,11 @@ describe('materializeEffect', () => {
|
|
|
48
48
|
expect(isWindowed('SELECT * FROM t LIMIT 10')).toBe(false);
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
describe('rowKey', () => {
|
|
53
|
+
it('reads a string id (SQLite) or a RecordId (SurrealDB); null when the row carries none', () => {
|
|
54
|
+
expect(rowKey({ id: 'thing:1' })).toBe('thing:1');
|
|
55
|
+
expect(rowKey({ id: new RecordId('thing', '2') })).toBe('thing:2');
|
|
56
|
+
for (const row of [{ name: 'x' }, { id: null }, { id: { other: 1 } }, null, 5]) expect(rowKey(row)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/query/materialize.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { RecordId } from 'surrealdb';
|
|
2
3
|
import type { Effect } from '../kernel/effects';
|
|
3
4
|
import { fx } from '../kernel/effects';
|
|
4
|
-
import { parseRecordIdString } from '../utils/index';
|
|
5
|
+
import { encodeRecordId, parseRecordIdString } from '../utils/index';
|
|
5
6
|
import { buildIdSetPlan, buildIdSetSurql, buildWindowMaterialization } from './window-query';
|
|
6
7
|
|
|
7
8
|
export interface MaterializeSource {
|
|
@@ -36,6 +37,18 @@ export function rowsFromResult(effect: Effect, result: unknown): Record<string,
|
|
|
36
37
|
return Array.isArray(first) ? (first as Record<string, unknown>[]) : [];
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* A row's encoded record id: a string on the SQLite engine, a `RecordId` on
|
|
42
|
+
* the SurrealDB one. `null` for a row that carries none (a projection
|
|
43
|
+
* without `id`, a `SELECT VALUE`).
|
|
44
|
+
*/
|
|
45
|
+
export function rowKey(row: unknown): string | null {
|
|
46
|
+
const id = row !== null && typeof row === 'object' ? (row as { id?: unknown }).id : undefined;
|
|
47
|
+
if (typeof id === 'string') return id;
|
|
48
|
+
if (id !== null && typeof id === 'object' && 'table' in id && 'id' in id) return encodeRecordId(id as RecordId<string>);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
/** Cheap structural equality for the "did the rows change" check. */
|
|
40
53
|
export function rowsEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean {
|
|
41
54
|
if (a === b) return true;
|