@spooky-sync/core 0.0.1-canary.204 → 0.0.1-canary.205
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 +148 -40
- package/dist/index.js +601 -173
- package/dist/tabs-broker-worker.js +51 -13
- package/dist/types.d.ts +52 -13
- package/package.json +3 -3
- package/src/modules/cache/index.ts +21 -4
- package/src/modules/devtools/index.ts +3 -1
- package/src/modules/sync/engine.ts +76 -58
- package/src/modules/sync/sync.ts +65 -8
- package/src/services/database/cache-engine.ts +33 -0
- package/src/services/database/sqlite-cache-engine.test.ts +58 -0
- package/src/services/database/sqlite-cache-engine.ts +71 -1
- package/src/services/database/surreal-cache-engine.ts +20 -0
- package/src/services/stream-processor/index.ts +471 -225
- package/src/services/stream-processor/stream-processor.batch.test.ts +3 -13
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +77 -67
- package/src/services/stream-processor/wasm-types.ts +32 -1
- package/src/services/tabs/broker.test.ts +49 -0
- package/src/services/tabs/coordinator.test.ts +6 -1
- package/src/services/tabs/coordinator.ts +35 -3
- package/src/services/tabs/tabs-broker-worker.ts +66 -12
- package/src/sp00ky.ts +54 -8
- package/src/types.ts +21 -13
|
@@ -5,7 +5,8 @@ import type { Logger } from 'pino';
|
|
|
5
5
|
import type { LocalStore } from '../database/index';
|
|
6
6
|
import type { WasmProcessor, WasmStreamUpdate } from './wasm-types';
|
|
7
7
|
import type { Duration } from 'surrealdb';
|
|
8
|
-
import type {
|
|
8
|
+
import type { QueryTimeToLive, RecordVersionArray } from '../../types';
|
|
9
|
+
import { encodeRecordId } from '../../utils/index';
|
|
9
10
|
|
|
10
11
|
// Simple interface for query plan registration (replaces Incantation class)
|
|
11
12
|
interface QueryPlanConfig {
|
|
@@ -57,19 +58,56 @@ export interface StreamUpdateReceiver {
|
|
|
57
58
|
onStreamUpdate(update: StreamUpdate): void;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** One row change in the shape `ingestMany` consumes. */
|
|
62
|
+
export interface IngestRecord {
|
|
63
|
+
table: string;
|
|
64
|
+
/** `MERGE` overlays the given fields on the stored row (projection widening). */
|
|
65
|
+
op: 'CREATE' | 'UPDATE' | 'DELETE' | 'MERGE';
|
|
66
|
+
id: string;
|
|
67
|
+
record: any;
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
71
|
+
* What the boot-time prime needs from the client: which tables to walk, how
|
|
72
|
+
* to recognise a snapshot written under a different schema, and which rows'
|
|
73
|
+
* local `_00_rv` must not be reported as the server's.
|
|
63
74
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
export interface CircuitPrimeContext {
|
|
76
|
+
tables: string[];
|
|
77
|
+
schemaHash: string;
|
|
78
|
+
/** Encoded ids with an unsettled local mutation (their `_00_rv` was bumped
|
|
79
|
+
* locally and may exceed the server's next version). */
|
|
80
|
+
pendingIds: Set<string>;
|
|
81
|
+
/** Receives every `(id, rv)` the prime put into the circuit, per table, so
|
|
82
|
+
* the sync layer can skip re-downloading bodies it already has. */
|
|
83
|
+
onVersions?: (table: string, entries: [string, number][]) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Storage key of the circuit snapshot inside the local store. */
|
|
87
|
+
export const CIRCUIT_SNAPSHOT_KEY = 'circuit';
|
|
88
|
+
/** Bump when the bytes `load_store_state` reads change shape. */
|
|
89
|
+
export const CIRCUIT_SNAPSHOT_FORMAT = 1;
|
|
90
|
+
/** Rows per `ingest_many` call. Bounds the transient the wasm side allocates
|
|
91
|
+
* to parse a batch (measured: one 7700-row call of 20 KB bodies peaked at
|
|
92
|
+
* 500 MB, 128-row chunks at 335 MB, and under projection at 24 MB). */
|
|
93
|
+
const INGEST_CHUNK = 128;
|
|
94
|
+
/** Ids per `selectByIds` when priming bodies out of the local store. */
|
|
95
|
+
const PRIME_CHUNK = 256;
|
|
96
|
+
/** Rows the SurrealDB (main-thread, IndexedDB) engine is allowed to prime
|
|
97
|
+
* through; above this the circuit boots empty as it always did. */
|
|
98
|
+
const SURREAL_PRIME_ROW_CAP = 20_000;
|
|
99
|
+
/** Below this many changed rows the checkpoint timer stays quiet. */
|
|
100
|
+
const CHECKPOINT_MIN_ROWS = 50;
|
|
101
|
+
|
|
102
|
+
/** Stable `table:id` for a row id that may already be a string. */
|
|
103
|
+
function idString(id: unknown): string {
|
|
104
|
+
return typeof id === 'string' ? id : encodeRecordId(id as any);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function chunks<T>(items: T[], size: number): T[][] {
|
|
108
|
+
const out: T[][] = [];
|
|
109
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
110
|
+
return out;
|
|
73
111
|
}
|
|
74
112
|
|
|
75
113
|
export class StreamProcessorService {
|
|
@@ -90,51 +128,42 @@ export class StreamProcessorService {
|
|
|
90
128
|
// rejecting `$auth`-gated tables; the predicate just degrades to its public
|
|
91
129
|
// branch. Set via `setSessionAuth` on every auth state change.
|
|
92
130
|
private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// per-user bucket itself.
|
|
97
|
-
private stateKeySuffix = '';
|
|
98
|
-
// Bumped by `reset()`. A `saveState` captured before a reset must not persist
|
|
99
|
-
// the OLD processor's circuit (holding the previous user's rows) after the
|
|
100
|
-
// switch — the fire-and-forget calls in `ingest`/`flushCoalescing` can land
|
|
101
|
-
// late.
|
|
131
|
+
// Bumped by `reset()`. Anything captured before a reset (a checkpoint's
|
|
132
|
+
// bytes, a prime's chunk, a widening read) must not land on the NEW
|
|
133
|
+
// processor, which belongs to a different bucket.
|
|
102
134
|
private stateGeneration = 0;
|
|
103
|
-
// Shared-tabs: follower circuits are in-memory ONLY.
|
|
104
|
-
//
|
|
105
|
-
// localStorage hazard); a promoted follower flips this back on.
|
|
135
|
+
// Shared-tabs: follower circuits are in-memory ONLY. Only the tab that owns
|
|
136
|
+
// the store writes its snapshot; a promoted follower flips this back on.
|
|
106
137
|
private persistState = true;
|
|
107
|
-
// Snapshot persistence
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// `unregisterQueryPlan`, i.e. once per sync batch AND once per query
|
|
113
|
-
// register/unregister. On a 3.7k-game collection whose list registers and
|
|
114
|
-
// drops one windowed query per 50 rows scrolled, that measured 220 whole-store
|
|
115
|
-
// serializations and ~1 GB of transient JSON for a single scroll, 5.5x the
|
|
116
|
-
// wall time of the same ingests without it, and it doubled the wasm heap
|
|
117
|
-
// high-water mark (wasm32 dlmalloc never returns pages, so the peak is
|
|
118
|
-
// permanent). It also ran the encode a second time in
|
|
119
|
-
// `LocalStoragePersistenceClient.set` and wrote it synchronously on the main
|
|
120
|
-
// thread. All of it was dead weight: `loadState`'s shape check could never
|
|
121
|
-
// match what either shipped persistence client returns, so the browser never
|
|
122
|
-
// restored a snapshot, and first paint comes from the local SQLite store
|
|
123
|
-
// anyway.
|
|
124
|
-
//
|
|
125
|
-
// When enabled, we mark the circuit dirty and let a checkpoint timer (plus a
|
|
126
|
-
// `pagehide` flush) do at most one snapshot per interval, mirroring
|
|
127
|
-
// `ssp-node`'s "NEVER per-ingest" rule.
|
|
138
|
+
// Snapshot persistence. Store-only bytes, written by the checkpoint timer
|
|
139
|
+
// and on the page going hidden, never per ingest: `save_store_state` walks
|
|
140
|
+
// every row. The snapshot is what lets a reload restore the circuit and
|
|
141
|
+
// step in only what changed since (`reconcile`), instead of re-downloading
|
|
142
|
+
// and re-ingesting the whole working set.
|
|
128
143
|
private persistCircuit = false;
|
|
129
144
|
private checkpointMs = 30_000;
|
|
130
145
|
private checkpointTimer: ReturnType<typeof setInterval> | null = null;
|
|
131
146
|
private snapshotDirty = false;
|
|
132
|
-
private
|
|
147
|
+
private dirtyRows = 0;
|
|
148
|
+
private hideHandler: (() => void) | null = null;
|
|
149
|
+
private checkpointInFlight: Promise<void> | null = null;
|
|
150
|
+
// Keep only the fields registered plans evaluate per stored row. The client
|
|
151
|
+
// renders bodies from the local store, so a 20 KB game row in the circuit
|
|
152
|
+
// is ~150 B of predicate/sort fields under projection.
|
|
153
|
+
private projection = true;
|
|
154
|
+
// Prime state: `primed` resolves when the boot-time prime (snapshot restore
|
|
155
|
+
// + reconcile, or a full read of the local store) has finished, aborted or
|
|
156
|
+
// been skipped. Sync waits on it before its first diff.
|
|
157
|
+
private primed: Promise<void> = Promise.resolve();
|
|
158
|
+
private schemaHash: string | null = null;
|
|
159
|
+
// Projection widening, serialised: fields a new view evaluates that stored
|
|
160
|
+
// rows lack are merged in table by table.
|
|
161
|
+
private widenQueue: Promise<void> = Promise.resolve();
|
|
162
|
+
private widenPending: Map<string, Set<string>> = new Map();
|
|
133
163
|
|
|
134
164
|
constructor(
|
|
135
165
|
public events: EventSystem<StreamProcessorEvents>,
|
|
136
166
|
private db: LocalStore,
|
|
137
|
-
private persistenceClient: PersistenceClient,
|
|
138
167
|
logger: Logger
|
|
139
168
|
) {
|
|
140
169
|
this.logger = logger.child({ name: 'StreamProcessorService' });
|
|
@@ -183,101 +212,89 @@ export class StreamProcessorService {
|
|
|
183
212
|
}
|
|
184
213
|
|
|
185
214
|
/**
|
|
186
|
-
* Ingest a batch of record changes
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* records land at once — e.g. sync fetching N missing rows — so a list query
|
|
190
|
-
* re-runs and the UI re-renders once for the whole batch rather than
|
|
191
|
-
* row-by-row.
|
|
215
|
+
* Ingest a batch of record changes, firing one coalesced `StreamUpdate` per
|
|
216
|
+
* affected query once every record has been ingested. Use this whenever
|
|
217
|
+
* multiple records land at once (sync fetching N rows, the boot prime).
|
|
192
218
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* batch
|
|
219
|
+
* The batch is fed to the wasm side in chunks of {@link INGEST_CHUNK}: one
|
|
220
|
+
* circuit step per chunk (a step walks every registered view, so per-record
|
|
221
|
+
* ingest paid that fixed cost N times), but never the whole batch at once,
|
|
222
|
+
* because the wasm side has to hold every parsed row of a call at the same
|
|
223
|
+
* time and wasm32 dlmalloc never returns that peak.
|
|
224
|
+
*
|
|
225
|
+
* Returns the records that were ingested. A chunk that fails is reported
|
|
226
|
+
* and skipped, not retried (a retry would double-apply whatever the failed
|
|
227
|
+
* step already committed), and the remaining chunks still run.
|
|
196
228
|
*/
|
|
197
|
-
ingestMany(
|
|
198
|
-
records
|
|
199
|
-
table: string;
|
|
200
|
-
op: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
201
|
-
id: string;
|
|
202
|
-
record: any;
|
|
203
|
-
}>
|
|
204
|
-
): void {
|
|
205
|
-
if (records.length === 0) return;
|
|
229
|
+
ingestMany(records: IngestRecord[]): IngestRecord[] {
|
|
230
|
+
if (records.length === 0) return [];
|
|
206
231
|
|
|
207
232
|
if (!this.processor) {
|
|
208
233
|
this.logger.warn(
|
|
209
234
|
{ Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
|
|
210
235
|
'Not initialized, skipping ingest'
|
|
211
236
|
);
|
|
212
|
-
return;
|
|
237
|
+
return [];
|
|
213
238
|
}
|
|
214
239
|
|
|
215
|
-
// One circuit step for the whole batch when the WASM build offers it. A
|
|
216
|
-
// step walks every registered view, so the per-record path pays that fixed
|
|
217
|
-
// cost N times: a cold sync landing a few thousand rows spent seconds of
|
|
218
|
-
// main-thread time almost entirely on step overhead. Older builds have no
|
|
219
|
-
// `ingest_many`, hence the loop below.
|
|
220
240
|
const bulkIngest = this.processor.ingest_many;
|
|
221
|
-
if (typeof bulkIngest
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
'Ingesting batch into ssp'
|
|
225
|
-
);
|
|
241
|
+
if (typeof bulkIngest !== 'function') {
|
|
242
|
+
// Stale wasm build: per-record steps, still coalesced into one update.
|
|
243
|
+
this.beginCoalescing();
|
|
226
244
|
try {
|
|
227
|
-
const
|
|
228
|
-
table
|
|
229
|
-
op: record.op,
|
|
230
|
-
id: record.id,
|
|
231
|
-
record: this.normalizeValue(record.record),
|
|
232
|
-
}));
|
|
233
|
-
const t0 = performance.now();
|
|
234
|
-
const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
|
|
235
|
-
const materializationTimeMs = performance.now() - t0;
|
|
236
|
-
this.logger.debug(
|
|
237
|
-
{
|
|
238
|
-
count: records.length,
|
|
239
|
-
rawUpdates: rawUpdates.length,
|
|
240
|
-
materializationTimeMs,
|
|
241
|
-
Category: 'sp00ky-client::StreamProcessorService::ingestMany',
|
|
242
|
-
},
|
|
243
|
-
'Ingesting batch into ssp done'
|
|
244
|
-
);
|
|
245
|
-
if (rawUpdates.length > 0) {
|
|
246
|
-
// `op: 'CREATE'` for the same reason the coalesced flush uses it: the
|
|
247
|
-
// batch's update takes DataModule's immediate (non-debounced) path.
|
|
248
|
-
this.dispatchUpdates(
|
|
249
|
-
rawUpdates.map((u: WasmStreamUpdate) => ({
|
|
250
|
-
queryHash: u.query_id,
|
|
251
|
-
localArray: u.result_data,
|
|
252
|
-
op: 'CREATE' as const,
|
|
253
|
-
materializationTimeMs,
|
|
254
|
-
storeApplyMs: u.timing_store_apply_ms,
|
|
255
|
-
circuitStepMs: u.timing_circuit_step_ms,
|
|
256
|
-
transformMs: u.timing_transform_ms,
|
|
257
|
-
}))
|
|
258
|
-
);
|
|
245
|
+
for (const record of records) {
|
|
246
|
+
this.ingest(record.table, record.op, record.id, record.record);
|
|
259
247
|
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// than re-running the batch, which would double-apply whatever the
|
|
263
|
-
// failed step already committed to the store.
|
|
264
|
-
this.logger.error(
|
|
265
|
-
{ error: e, count: records.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
|
|
266
|
-
'Ingesting batch into ssp failed'
|
|
267
|
-
);
|
|
248
|
+
} finally {
|
|
249
|
+
this.flushCoalescing();
|
|
268
250
|
}
|
|
269
|
-
|
|
270
|
-
return;
|
|
251
|
+
return records;
|
|
271
252
|
}
|
|
272
253
|
|
|
254
|
+
const ingested: IngestRecord[] = [];
|
|
273
255
|
this.beginCoalescing();
|
|
274
256
|
try {
|
|
275
|
-
for (const
|
|
276
|
-
|
|
257
|
+
for (const chunk of chunks(records, INGEST_CHUNK)) {
|
|
258
|
+
try {
|
|
259
|
+
const items = chunk.map((record) => ({
|
|
260
|
+
table: record.table,
|
|
261
|
+
op: record.op,
|
|
262
|
+
id: record.id,
|
|
263
|
+
record: this.normalizeValue(record.record),
|
|
264
|
+
}));
|
|
265
|
+
const t0 = performance.now();
|
|
266
|
+
const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
|
|
267
|
+
const materializationTimeMs = performance.now() - t0;
|
|
268
|
+
if (rawUpdates.length > 0) {
|
|
269
|
+
this.notifyUpdates(
|
|
270
|
+
rawUpdates.map((u: WasmStreamUpdate) => ({
|
|
271
|
+
queryHash: u.query_id,
|
|
272
|
+
localArray: u.result_data,
|
|
273
|
+
op: 'CREATE' as const,
|
|
274
|
+
materializationTimeMs,
|
|
275
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
276
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
277
|
+
transformMs: u.timing_transform_ms,
|
|
278
|
+
}))
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
ingested.push(...chunk);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
this.logger.error(
|
|
284
|
+
{ error: e, count: chunk.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
|
|
285
|
+
'Ingesting chunk into ssp failed'
|
|
286
|
+
);
|
|
287
|
+
}
|
|
277
288
|
}
|
|
278
289
|
} finally {
|
|
279
290
|
this.flushCoalescing();
|
|
280
291
|
}
|
|
292
|
+
this.logger.debug(
|
|
293
|
+
{ count: records.length, ingested: ingested.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
|
|
294
|
+
'Ingested batch into ssp'
|
|
295
|
+
);
|
|
296
|
+
this.markSnapshotDirty(ingested.length);
|
|
297
|
+
return ingested;
|
|
281
298
|
}
|
|
282
299
|
|
|
283
300
|
/**
|
|
@@ -308,7 +325,6 @@ export class StreamProcessorService {
|
|
|
308
325
|
if (buffered.length > 0) {
|
|
309
326
|
this.dispatchUpdates(buffered);
|
|
310
327
|
}
|
|
311
|
-
this.markSnapshotDirty();
|
|
312
328
|
}
|
|
313
329
|
|
|
314
330
|
/**
|
|
@@ -326,9 +342,7 @@ export class StreamProcessorService {
|
|
|
326
342
|
await init(); // Initialize the WASM module (web target)
|
|
327
343
|
// We cast the generated Sp00kyProcessor to our interface which is safer
|
|
328
344
|
this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
|
|
329
|
-
|
|
330
|
-
// Try to load state
|
|
331
|
-
await this.loadState();
|
|
345
|
+
this.applyProjection();
|
|
332
346
|
|
|
333
347
|
this.isInitialized = true;
|
|
334
348
|
this.logger.info(
|
|
@@ -344,25 +358,16 @@ export class StreamProcessorService {
|
|
|
344
358
|
}
|
|
345
359
|
}
|
|
346
360
|
|
|
347
|
-
/** Route the persisted circuit snapshot to a per-bucket key. */
|
|
348
|
-
setStateKeySuffix(bucketId: string) {
|
|
349
|
-
this.stateKeySuffix = bucketId;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
private stateKey(): string {
|
|
353
|
-
return this.stateKeySuffix
|
|
354
|
-
? `_00_stream_processor_state:${this.stateKeySuffix}`
|
|
355
|
-
: '_00_stream_processor_state';
|
|
356
|
-
}
|
|
357
|
-
|
|
358
361
|
/**
|
|
359
362
|
* Drop the current WASM processor and start a fresh, empty circuit. Used on
|
|
360
363
|
* local-bucket switches: the old circuit holds the previous user's rows AND
|
|
361
364
|
* views registered with the previous `$auth` context, so neither may survive.
|
|
362
|
-
* Deliberately
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
365
|
+
* Deliberately loads nothing: the snapshot in the store being swapped away
|
|
366
|
+
* from belongs to the previous bucket; the caller primes the new bucket's
|
|
367
|
+
* circuit (`primeFromLocal`) once its store is open, and the DataModule
|
|
368
|
+
* rebind re-registers every live view against this fresh processor. Caller
|
|
369
|
+
* must re-seed `setPermissions` afterwards (a fresh circuit default-denies
|
|
370
|
+
* every table).
|
|
366
371
|
*/
|
|
367
372
|
async reset(): Promise<void> {
|
|
368
373
|
if (!this.isInitialized) return;
|
|
@@ -370,8 +375,11 @@ export class StreamProcessorService {
|
|
|
370
375
|
this.batching = false;
|
|
371
376
|
this.batchBuffer.clear();
|
|
372
377
|
this.snapshotDirty = false;
|
|
378
|
+
this.dirtyRows = 0;
|
|
379
|
+
this.widenPending.clear();
|
|
373
380
|
const previous = this.processor;
|
|
374
381
|
this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
|
|
382
|
+
this.applyProjection();
|
|
375
383
|
this.freeProcessor(previous);
|
|
376
384
|
this.logger.info(
|
|
377
385
|
{ Category: 'sp00ky-client::StreamProcessorService::reset' },
|
|
@@ -418,9 +426,9 @@ export class StreamProcessorService {
|
|
|
418
426
|
}
|
|
419
427
|
|
|
420
428
|
/**
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
429
|
+
* Snapshot persistence (`persistCircuit`). When on, the circuit's store is
|
|
430
|
+
* written to the local store on a checkpoint interval and when the page
|
|
431
|
+
* goes hidden, and restored by {@link primeFromLocal} on the next boot.
|
|
424
432
|
*/
|
|
425
433
|
configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void {
|
|
426
434
|
this.persistCircuit = enabled;
|
|
@@ -429,87 +437,349 @@ export class StreamProcessorService {
|
|
|
429
437
|
}
|
|
430
438
|
|
|
431
439
|
/**
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
|
|
440
|
+
* Field projection (`circuitProjection`, default on). Takes effect on the
|
|
441
|
+
* next processor (`init`/`reset`) and on rows written after that.
|
|
442
|
+
*/
|
|
443
|
+
configureProjection(enabled: boolean): void {
|
|
444
|
+
this.projection = enabled;
|
|
445
|
+
this.applyProjection();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private applyProjection(): void {
|
|
449
|
+
if (!this.processor || typeof this.processor.set_projection !== 'function') return;
|
|
450
|
+
try {
|
|
451
|
+
this.processor.set_projection(this.projection);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
this.logger.warn(
|
|
454
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::applyProjection' },
|
|
455
|
+
'set_projection failed'
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Resolves once the boot-time prime has finished (or was skipped). */
|
|
461
|
+
whenPrimed(): Promise<void> {
|
|
462
|
+
return this.primed;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Fill the circuit from the LOCAL store, in the background.
|
|
467
|
+
*
|
|
468
|
+
* With a usable snapshot: install it under whatever views have registered
|
|
469
|
+
* meanwhile (`load_store_state` re-primes them), then `reconcile` each table
|
|
470
|
+
* against the store's `(id, rv)` list so rows deleted since the checkpoint
|
|
471
|
+
* are stepped out and only rows added or changed since are read back and
|
|
472
|
+
* ingested. Without one: read every row and ingest it, chunked.
|
|
473
|
+
*
|
|
474
|
+
* Either way the circuit ends up equal to the local store without touching
|
|
475
|
+
* the network, so the first sync diff is a real delta rather than "fetch
|
|
476
|
+
* everything". The returned promise never rejects; `whenPrimed` gates on it.
|
|
477
|
+
*/
|
|
478
|
+
primeFromLocal(ctx: CircuitPrimeContext): Promise<void> {
|
|
479
|
+
this.schemaHash = ctx.schemaHash;
|
|
480
|
+
const run = this.runPrime(ctx).catch((e) => {
|
|
481
|
+
this.logger.warn(
|
|
482
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
|
|
483
|
+
'Circuit prime failed; the circuit fills from sync instead'
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
this.primed = run;
|
|
487
|
+
return run;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async runPrime(ctx: CircuitPrimeContext): Promise<void> {
|
|
491
|
+
const processor = this.processor;
|
|
492
|
+
if (!processor || typeof this.db.scanVersions !== 'function') return;
|
|
493
|
+
const generation = this.stateGeneration;
|
|
494
|
+
const epoch = this.db.epoch;
|
|
495
|
+
// A bucket switch replaces the processor and the store under us; every
|
|
496
|
+
// step re-checks so a stale chunk never lands in the new bucket.
|
|
497
|
+
const alive = () =>
|
|
498
|
+
this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
|
|
499
|
+
const t0 = performance.now();
|
|
500
|
+
|
|
501
|
+
let restored = false;
|
|
502
|
+
if (
|
|
503
|
+
this.persistCircuit &&
|
|
504
|
+
typeof this.db.getSnapshot === 'function' &&
|
|
505
|
+
typeof processor.load_store_state === 'function'
|
|
506
|
+
) {
|
|
507
|
+
const snapshot = await this.db.getSnapshot(CIRCUIT_SNAPSHOT_KEY);
|
|
508
|
+
if (!alive()) return;
|
|
509
|
+
if (snapshot) {
|
|
510
|
+
const { meta } = snapshot;
|
|
511
|
+
if (meta.formatVersion !== CIRCUIT_SNAPSHOT_FORMAT || meta.schemaHash !== ctx.schemaHash) {
|
|
512
|
+
this.logger.info(
|
|
513
|
+
{ meta, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
|
|
514
|
+
'Circuit snapshot is from another format or schema; priming from rows'
|
|
515
|
+
);
|
|
516
|
+
} else {
|
|
517
|
+
try {
|
|
518
|
+
const tLoad = performance.now();
|
|
519
|
+
const updates = processor.load_store_state(snapshot.bytes);
|
|
520
|
+
this.dispatchWasmUpdates(updates);
|
|
521
|
+
restored = true;
|
|
522
|
+
this.logger.info(
|
|
523
|
+
{
|
|
524
|
+
bytes: snapshot.bytes.byteLength,
|
|
525
|
+
views: updates.length,
|
|
526
|
+
loadMs: Math.round(performance.now() - tLoad),
|
|
527
|
+
Category: 'sp00ky-client::StreamProcessorService::primeFromLocal',
|
|
528
|
+
},
|
|
529
|
+
'Circuit snapshot restored'
|
|
530
|
+
);
|
|
531
|
+
} catch (e) {
|
|
532
|
+
this.logger.warn(
|
|
533
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
|
|
534
|
+
'Circuit snapshot unreadable; priming from rows'
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Read the versions AFTER the snapshot: a batch the leader writes between
|
|
542
|
+
// the two can only make the store side newer, which reconcile handles.
|
|
543
|
+
const versions = await this.db.scanVersions(ctx.tables);
|
|
544
|
+
if (!alive()) return;
|
|
545
|
+
const total = Object.values(versions).reduce((n, v) => n + v.length, 0);
|
|
546
|
+
if (!restored && this.db.engineKind !== 'sqlite' && total > SURREAL_PRIME_ROW_CAP) {
|
|
547
|
+
this.logger.warn(
|
|
548
|
+
{ total, cap: SURREAL_PRIME_ROW_CAP, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
|
|
549
|
+
'Too many cached rows to prime through the main-thread engine; circuit fills from sync'
|
|
550
|
+
);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
let ingested = 0;
|
|
555
|
+
let fetched = 0;
|
|
556
|
+
let deleted = 0;
|
|
557
|
+
for (const table of ctx.tables) {
|
|
558
|
+
const entries = versions[table] ?? [];
|
|
559
|
+
let toFetch: string[];
|
|
560
|
+
if (restored && typeof processor.reconcile === 'function') {
|
|
561
|
+
const result = processor.reconcile(table, entries);
|
|
562
|
+
this.dispatchWasmUpdates(result.updates);
|
|
563
|
+
deleted += result.deleted;
|
|
564
|
+
toFetch = result.fetch;
|
|
565
|
+
} else {
|
|
566
|
+
toFetch = entries.map(([id]) => id);
|
|
567
|
+
}
|
|
568
|
+
for (const chunk of chunks(toFetch, PRIME_CHUNK)) {
|
|
569
|
+
const rows = await this.db.selectByIds(table, chunk);
|
|
570
|
+
if (!alive()) return;
|
|
571
|
+
fetched += rows.length;
|
|
572
|
+
ingested += this.ingestMany(
|
|
573
|
+
rows.map((row) => ({
|
|
574
|
+
table,
|
|
575
|
+
op: 'CREATE' as const,
|
|
576
|
+
id: idString(row.id),
|
|
577
|
+
record: row,
|
|
578
|
+
}))
|
|
579
|
+
).length;
|
|
580
|
+
}
|
|
581
|
+
if (entries.length > 0) {
|
|
582
|
+
ctx.onVersions?.(
|
|
583
|
+
table,
|
|
584
|
+
entries.filter(([id]) => !ctx.pendingIds.has(id))
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// A prime from rows leaves the store with no snapshot to fall back on;
|
|
589
|
+
// let the next checkpoint write one even if nothing else changes.
|
|
590
|
+
if (!restored && ingested > 0) this.markSnapshotDirty(Math.max(ingested, CHECKPOINT_MIN_ROWS));
|
|
591
|
+
this.logger.info(
|
|
592
|
+
{
|
|
593
|
+
restored,
|
|
594
|
+
rows: total,
|
|
595
|
+
fetched,
|
|
596
|
+
ingested,
|
|
597
|
+
deleted,
|
|
598
|
+
ms: Math.round(performance.now() - t0),
|
|
599
|
+
Category: 'sp00ky-client::StreamProcessorService::primeFromLocal',
|
|
600
|
+
},
|
|
601
|
+
'Circuit primed from the local store'
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Publish wasm updates produced outside an ingest (restore, reconcile). */
|
|
606
|
+
private dispatchWasmUpdates(updates: WasmStreamUpdate[] | undefined): void {
|
|
607
|
+
if (!updates || updates.length === 0) return;
|
|
608
|
+
this.dispatchUpdates(
|
|
609
|
+
updates.map((u) => ({
|
|
610
|
+
queryHash: u.query_id,
|
|
611
|
+
localArray: u.result_data,
|
|
612
|
+
op: 'CREATE' as const,
|
|
613
|
+
}))
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Record that the circuit changed by `rows` rows. Cheap; the snapshot is
|
|
619
|
+
* deferred to the checkpoint timer and skipped entirely when `persistCircuit`
|
|
620
|
+
* is off.
|
|
435
621
|
*/
|
|
436
|
-
private markSnapshotDirty(): void {
|
|
622
|
+
private markSnapshotDirty(rows = 1): void {
|
|
437
623
|
if (!this.persistCircuit || !this.persistState) return;
|
|
438
624
|
this.snapshotDirty = true;
|
|
625
|
+
this.dirtyRows += rows;
|
|
439
626
|
this.startCheckpoints();
|
|
440
627
|
}
|
|
441
628
|
|
|
442
629
|
private startCheckpoints(): void {
|
|
443
630
|
if (this.checkpointTimer) return;
|
|
444
631
|
this.checkpointTimer = setInterval(() => {
|
|
445
|
-
if (!this.snapshotDirty) return;
|
|
446
|
-
this.
|
|
447
|
-
void this.saveState();
|
|
632
|
+
if (!this.snapshotDirty || this.dirtyRows < CHECKPOINT_MIN_ROWS) return;
|
|
633
|
+
void this.checkpoint('interval');
|
|
448
634
|
}, this.checkpointMs);
|
|
449
635
|
// Node/test environments have no `window`; the interval alone is enough there.
|
|
450
|
-
if (typeof window !== 'undefined' && !this.
|
|
451
|
-
|
|
636
|
+
if (typeof window !== 'undefined' && !this.hideHandler) {
|
|
637
|
+
// `visibilitychange: hidden` is the reliable last chance: a 100 MB write
|
|
638
|
+
// cannot finish inside `pagehide`, which is kept only as best effort.
|
|
639
|
+
this.hideHandler = () => {
|
|
452
640
|
if (!this.snapshotDirty) return;
|
|
453
|
-
|
|
454
|
-
void this.
|
|
641
|
+
if (typeof document !== 'undefined' && document.visibilityState === 'visible') return;
|
|
642
|
+
void this.checkpoint('hidden');
|
|
455
643
|
};
|
|
456
|
-
window.addEventListener('
|
|
644
|
+
window.addEventListener('visibilitychange', this.hideHandler);
|
|
645
|
+
window.addEventListener('pagehide', this.hideHandler);
|
|
457
646
|
}
|
|
458
647
|
}
|
|
459
648
|
|
|
460
|
-
/** Stop checkpointing and drop the
|
|
649
|
+
/** Stop checkpointing and drop the visibility listeners. */
|
|
461
650
|
stopCheckpoints(): void {
|
|
462
651
|
if (this.checkpointTimer) {
|
|
463
652
|
clearInterval(this.checkpointTimer);
|
|
464
653
|
this.checkpointTimer = null;
|
|
465
654
|
}
|
|
466
|
-
if (this.
|
|
467
|
-
window.removeEventListener('
|
|
655
|
+
if (this.hideHandler && typeof window !== 'undefined') {
|
|
656
|
+
window.removeEventListener('visibilitychange', this.hideHandler);
|
|
657
|
+
window.removeEventListener('pagehide', this.hideHandler);
|
|
468
658
|
}
|
|
469
|
-
this.
|
|
659
|
+
this.hideHandler = null;
|
|
470
660
|
this.snapshotDirty = false;
|
|
661
|
+
this.dirtyRows = 0;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Write the circuit's store to the local store as a snapshot. Compacts the
|
|
666
|
+
* row arena first when dead bytes outweigh live ones. Serialised: a second
|
|
667
|
+
* call while one is in flight joins it. No-op unless persistence is on, this
|
|
668
|
+
* tab owns the store, and the engine can hold a snapshot.
|
|
669
|
+
*/
|
|
670
|
+
checkpoint(reason = 'manual'): Promise<void> {
|
|
671
|
+
if (this.checkpointInFlight) return this.checkpointInFlight;
|
|
672
|
+
const run = this.runCheckpoint(reason).finally(() => {
|
|
673
|
+
this.checkpointInFlight = null;
|
|
674
|
+
});
|
|
675
|
+
this.checkpointInFlight = run;
|
|
676
|
+
return run;
|
|
471
677
|
}
|
|
472
678
|
|
|
473
|
-
async
|
|
474
|
-
|
|
679
|
+
private async runCheckpoint(reason: string): Promise<void> {
|
|
680
|
+
const processor = this.processor;
|
|
681
|
+
if (!processor || !this.persistState || !this.persistCircuit || !this.schemaHash) return;
|
|
682
|
+
if (typeof this.db.putSnapshot !== 'function' || typeof processor.save_store_state !== 'function') {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const generation = this.stateGeneration;
|
|
686
|
+
const epoch = this.db.epoch;
|
|
687
|
+
const rows = this.dirtyRows;
|
|
688
|
+
this.snapshotDirty = false;
|
|
689
|
+
this.dirtyRows = 0;
|
|
475
690
|
try {
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (state) {
|
|
486
|
-
this.logger.info(
|
|
487
|
-
{
|
|
488
|
-
stateLength: state.length,
|
|
489
|
-
Category: 'sp00ky-client::StreamProcessorService::loadState',
|
|
490
|
-
},
|
|
491
|
-
'Loading state from DB'
|
|
492
|
-
);
|
|
493
|
-
// Assuming processor has a load_state method matching the save_state behavior
|
|
494
|
-
// If not, we might need to adjust based on the actual WASM API
|
|
495
|
-
if (typeof this.processor.load_state === 'function') {
|
|
496
|
-
this.processor.load_state(state);
|
|
497
|
-
} else {
|
|
498
|
-
this.logger.warn(
|
|
499
|
-
{ Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
500
|
-
'load_state method not found on processor'
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
} else {
|
|
504
|
-
this.logger.info(
|
|
505
|
-
{ Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
506
|
-
'No saved state found'
|
|
507
|
-
);
|
|
691
|
+
const t0 = performance.now();
|
|
692
|
+
let reclaimed = 0;
|
|
693
|
+
if (
|
|
694
|
+
typeof processor.compact === 'function' &&
|
|
695
|
+
typeof processor.dead_bytes === 'function' &&
|
|
696
|
+
typeof processor.live_bytes === 'function'
|
|
697
|
+
) {
|
|
698
|
+
const dead = processor.dead_bytes();
|
|
699
|
+
if (dead > 1_000_000 && dead > processor.live_bytes()) reclaimed = processor.compact();
|
|
508
700
|
}
|
|
701
|
+
const bytes = processor.save_store_state();
|
|
702
|
+
const meta = {
|
|
703
|
+
formatVersion: CIRCUIT_SNAPSHOT_FORMAT,
|
|
704
|
+
schemaHash: this.schemaHash,
|
|
705
|
+
savedAt: Date.now(),
|
|
706
|
+
maxRv: typeof processor.max_row_versions === 'function' ? processor.max_row_versions() : undefined,
|
|
707
|
+
};
|
|
708
|
+
// A reset or bucket switch landed while we serialised: this snapshot
|
|
709
|
+
// describes the OLD bucket's circuit and must not be written into the
|
|
710
|
+
// new bucket's store.
|
|
711
|
+
if (generation !== this.stateGeneration || epoch !== this.db.epoch) return;
|
|
712
|
+
await this.db.putSnapshot(CIRCUIT_SNAPSHOT_KEY, bytes, meta);
|
|
713
|
+
this.logger.info(
|
|
714
|
+
{
|
|
715
|
+
reason,
|
|
716
|
+
rows,
|
|
717
|
+
bytes: bytes.byteLength,
|
|
718
|
+
reclaimed,
|
|
719
|
+
ms: Math.round(performance.now() - t0),
|
|
720
|
+
Category: 'sp00ky-client::StreamProcessorService::checkpoint',
|
|
721
|
+
},
|
|
722
|
+
'Circuit snapshot written'
|
|
723
|
+
);
|
|
509
724
|
} catch (e) {
|
|
510
|
-
this.logger.
|
|
511
|
-
{ error: e, Category: 'sp00ky-client::StreamProcessorService::
|
|
512
|
-
'
|
|
725
|
+
this.logger.warn(
|
|
726
|
+
{ error: e, reason, Category: 'sp00ky-client::StreamProcessorService::checkpoint' },
|
|
727
|
+
'Circuit snapshot failed'
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Projection widening: a newly registered view evaluates fields that rows
|
|
734
|
+
* already in the circuit were stored without. Merge just those fields in,
|
|
735
|
+
* table by table, from the local store. The view registered against what
|
|
736
|
+
* was present and converges as the merges step through.
|
|
737
|
+
*/
|
|
738
|
+
private scheduleWiden(missing: Record<string, string[]>): void {
|
|
739
|
+
for (const [table, fields] of Object.entries(missing)) {
|
|
740
|
+
const set = this.widenPending.get(table) ?? new Set<string>();
|
|
741
|
+
for (const f of fields) set.add(f);
|
|
742
|
+
this.widenPending.set(table, set);
|
|
743
|
+
}
|
|
744
|
+
this.widenQueue = this.widenQueue.then(() => this.runWiden()).catch((e) => {
|
|
745
|
+
this.logger.warn(
|
|
746
|
+
{ error: e, Category: 'sp00ky-client::StreamProcessorService::widen' },
|
|
747
|
+
'Projection widening failed'
|
|
748
|
+
);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private async runWiden(): Promise<void> {
|
|
753
|
+
const processor = this.processor;
|
|
754
|
+
if (!processor || typeof this.db.scanVersions !== 'function') {
|
|
755
|
+
this.widenPending.clear();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const generation = this.stateGeneration;
|
|
759
|
+
const epoch = this.db.epoch;
|
|
760
|
+
while (this.widenPending.size > 0) {
|
|
761
|
+
const [table, fields] = this.widenPending.entries().next().value as [string, Set<string>];
|
|
762
|
+
this.widenPending.delete(table);
|
|
763
|
+
const select = Array.from(fields);
|
|
764
|
+
const versions = await this.db.scanVersions([table]);
|
|
765
|
+
if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
|
|
766
|
+
const ids = (versions[table] ?? []).map(([id]) => id);
|
|
767
|
+
let merged = 0;
|
|
768
|
+
for (const chunk of chunks(ids, PRIME_CHUNK)) {
|
|
769
|
+
const rows = await this.db.selectByIds(table, chunk, { select });
|
|
770
|
+
if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
|
|
771
|
+
merged += this.ingestMany(
|
|
772
|
+
rows.map((row) => ({
|
|
773
|
+
table,
|
|
774
|
+
op: 'MERGE' as const,
|
|
775
|
+
id: idString(row.id),
|
|
776
|
+
record: row,
|
|
777
|
+
}))
|
|
778
|
+
).length;
|
|
779
|
+
}
|
|
780
|
+
this.logger.info(
|
|
781
|
+
{ table, fields: select, merged, Category: 'sp00ky-client::StreamProcessorService::widen' },
|
|
782
|
+
'Widened projected rows with newly evaluated fields'
|
|
513
783
|
);
|
|
514
784
|
}
|
|
515
785
|
}
|
|
@@ -560,32 +830,6 @@ export class StreamProcessorService {
|
|
|
560
830
|
);
|
|
561
831
|
}
|
|
562
832
|
|
|
563
|
-
async saveState() {
|
|
564
|
-
if (!this.processor || !this.persistState || !this.persistCircuit) return;
|
|
565
|
-
const generation = this.stateGeneration;
|
|
566
|
-
try {
|
|
567
|
-
// Assuming processor has a save_state method that returns the state string/bytes
|
|
568
|
-
if (typeof this.processor.save_state === 'function') {
|
|
569
|
-
const state = this.processor.save_state();
|
|
570
|
-
// A reset raced this snapshot — persisting it would write the previous
|
|
571
|
-
// user's circuit into the new bucket's key. Drop it.
|
|
572
|
-
if (generation !== this.stateGeneration) return;
|
|
573
|
-
if (state) {
|
|
574
|
-
await this.persistenceClient.set(this.stateKey(), state);
|
|
575
|
-
this.logger.trace(
|
|
576
|
-
{ Category: 'sp00ky-client::StreamProcessorService::saveState' },
|
|
577
|
-
'State saved'
|
|
578
|
-
);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
} catch (e) {
|
|
582
|
-
this.logger.error(
|
|
583
|
-
{ error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
|
|
584
|
-
'Failed to save state'
|
|
585
|
-
);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
833
|
/**
|
|
590
834
|
* Ingest a record change into the processor.
|
|
591
835
|
* Emits 'stream_update' event if materialized views are affected.
|
|
@@ -593,7 +837,7 @@ export class StreamProcessorService {
|
|
|
593
837
|
*/
|
|
594
838
|
ingest(
|
|
595
839
|
table: string,
|
|
596
|
-
op: '
|
|
840
|
+
op: IngestRecord['op'],
|
|
597
841
|
id: string,
|
|
598
842
|
record: any
|
|
599
843
|
): WasmStreamUpdate[] {
|
|
@@ -637,7 +881,8 @@ export class StreamProcessorService {
|
|
|
637
881
|
const updates: StreamUpdate[] = rawUpdates.map((u: WasmStreamUpdate) => ({
|
|
638
882
|
queryHash: u.query_id,
|
|
639
883
|
localArray: u.result_data,
|
|
640
|
-
|
|
884
|
+
// A MERGE is a content update as far as consumers are concerned.
|
|
885
|
+
op: op === 'MERGE' ? 'UPDATE' : op,
|
|
641
886
|
materializationTimeMs,
|
|
642
887
|
storeApplyMs: u.timing_store_apply_ms,
|
|
643
888
|
circuitStepMs: u.timing_circuit_step_ms,
|
|
@@ -726,7 +971,9 @@ export class StreamProcessorService {
|
|
|
726
971
|
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
|
|
727
972
|
},
|
|
728
973
|
};
|
|
729
|
-
|
|
974
|
+
if (initialUpdate.missing_fields && Object.keys(initialUpdate.missing_fields).length > 0) {
|
|
975
|
+
this.scheduleWiden(initialUpdate.missing_fields);
|
|
976
|
+
}
|
|
730
977
|
this.logger.debug(
|
|
731
978
|
{
|
|
732
979
|
queryHash: queryPlan.queryHash,
|
|
@@ -753,7 +1000,6 @@ export class StreamProcessorService {
|
|
|
753
1000
|
if (!this.processor) return;
|
|
754
1001
|
try {
|
|
755
1002
|
this.processor.unregister_view(queryHash);
|
|
756
|
-
this.markSnapshotDirty();
|
|
757
1003
|
} catch (e) {
|
|
758
1004
|
this.logger.error(
|
|
759
1005
|
{ error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
|