@powersync/service-core 1.23.1 → 1.23.3
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/CHANGELOG.md +27 -0
- package/dist/api/RouteAPI.d.ts +25 -11
- package/dist/replication/AbstractReplicator.d.ts +8 -0
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/RouterEngine.d.ts +1 -0
- package/dist/routes/RouterEngine.js +6 -1
- package/dist/routes/RouterEngine.js.map +1 -1
- package/dist/routes/configure-fastify.d.ts +52 -44
- package/dist/routes/configure-fastify.js +15 -2
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/checkpointing.js +1 -3
- package/dist/routes/endpoints/checkpointing.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +2 -1
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/storage/BucketStorageBatch.d.ts +60 -11
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/ChecksumCache.d.ts +11 -6
- package/dist/storage/ChecksumCache.js +5 -4
- package/dist/storage/ChecksumCache.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +35 -5
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/storage/WriteCheckpointAPI.d.ts +1 -1
- package/dist/sync/BucketChecksumState.d.ts +5 -2
- package/dist/sync/BucketChecksumState.js +36 -8
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/RequestTracker.d.ts +7 -0
- package/dist/sync/RequestTracker.js +12 -0
- package/dist/sync/RequestTracker.js.map +1 -1
- package/dist/sync/sync.js +118 -38
- package/dist/sync/sync.js.map +1 -1
- package/dist/system/ServiceContext.d.ts +2 -0
- package/dist/system/ServiceContext.js +2 -0
- package/dist/system/ServiceContext.js.map +1 -1
- package/dist/tracing/PerformanceTracer.d.ts +7 -1
- package/dist/tracing/PerformanceTracer.js +8 -0
- package/dist/tracing/PerformanceTracer.js.map +1 -1
- package/dist/util/checkpointing.d.ts +3 -8
- package/dist/util/checkpointing.js +1 -16
- package/dist/util/checkpointing.js.map +1 -1
- package/dist/util/config/compound-config-collector.js +10 -2
- package/dist/util/config/compound-config-collector.js.map +1 -1
- package/dist/util/config/defaults.d.ts +1 -0
- package/dist/util/config/defaults.js +1 -0
- package/dist/util/config/defaults.js.map +1 -1
- package/dist/util/config/types.d.ts +1 -0
- package/dist/util/util-index.d.ts +1 -0
- package/dist/util/util-index.js +1 -0
- package/dist/util/util-index.js.map +1 -1
- package/dist/util/utils.d.ts +1 -1
- package/dist/util/utils.js +1 -1
- package/dist/util/write-checkpoint-batcher.d.ts +19 -0
- package/dist/util/write-checkpoint-batcher.js +90 -0
- package/dist/util/write-checkpoint-batcher.js.map +1 -0
- package/package.json +5 -5
- package/src/api/RouteAPI.ts +25 -11
- package/src/replication/AbstractReplicator.ts +8 -0
- package/src/routes/RouterEngine.ts +7 -1
- package/src/routes/configure-fastify.ts +25 -2
- package/src/routes/endpoints/checkpointing.ts +1 -4
- package/src/routes/endpoints/socket-route.ts +2 -1
- package/src/storage/BucketStorageBatch.ts +61 -11
- package/src/storage/ChecksumCache.ts +27 -14
- package/src/storage/SyncRulesBucketStorage.ts +42 -5
- package/src/storage/WriteCheckpointAPI.ts +1 -1
- package/src/sync/BucketChecksumState.ts +65 -8
- package/src/sync/RequestTracker.ts +20 -0
- package/src/sync/sync.ts +148 -44
- package/src/system/ServiceContext.ts +7 -0
- package/src/tracing/PerformanceTracer.ts +18 -1
- package/src/util/checkpointing.ts +3 -22
- package/src/util/config/compound-config-collector.ts +13 -1
- package/src/util/config/defaults.ts +1 -0
- package/src/util/config/types.ts +1 -0
- package/src/util/util-index.ts +1 -0
- package/src/util/utils.ts +1 -1
- package/src/util/write-checkpoint-batcher.ts +122 -0
- package/test/src/checksum_cache.test.ts +22 -0
- package/test/src/config.test.ts +42 -0
- package/test/src/sync/BucketChecksumState.test.ts +41 -1
- package/test/src/util/checkpointing.test.ts +170 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/sync/sync.ts
CHANGED
|
@@ -9,11 +9,19 @@ import * as util from '../util/util-index.js';
|
|
|
9
9
|
|
|
10
10
|
import { Logger, logger as defaultLogger } from '@powersync/lib-services-framework';
|
|
11
11
|
import { mergeAsyncIterables } from '../streams/streams-index.js';
|
|
12
|
-
import {
|
|
12
|
+
import { PerformanceTracer, type Span } from '../tracing/PerformanceTracer.js';
|
|
13
|
+
import { BucketChecksumState, CheckpointLine, type SyncCheckpointTraceCategory } from './BucketChecksumState.js';
|
|
13
14
|
import { OperationsSentStats, RequestTracker, statsForBatch } from './RequestTracker.js';
|
|
14
15
|
import { SyncContext } from './SyncContext.js';
|
|
15
16
|
import { TokenStreamOptions, acquireSemaphoreAbortable, settledPromise, tokenStream } from './util.js';
|
|
16
17
|
|
|
18
|
+
type CheckpointTiming = Record<string, number>;
|
|
19
|
+
|
|
20
|
+
interface ActiveCheckpointTrace {
|
|
21
|
+
tracer: PerformanceTracer<SyncCheckpointTraceCategory>;
|
|
22
|
+
span: Span;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
export interface SyncStreamParameters {
|
|
18
26
|
syncContext: SyncContext;
|
|
19
27
|
bucketStorage: storage.SyncRulesBucketStorage;
|
|
@@ -119,8 +127,9 @@ async function* streamResponseInner(
|
|
|
119
127
|
const newCheckpoints = stream[Symbol.asyncIterator]();
|
|
120
128
|
|
|
121
129
|
type CheckpointAndLine = {
|
|
122
|
-
checkpoint:
|
|
130
|
+
checkpoint: storage.ReplicationCheckpoint;
|
|
123
131
|
line: CheckpointLine | null;
|
|
132
|
+
trace: ActiveCheckpointTrace | null;
|
|
124
133
|
};
|
|
125
134
|
|
|
126
135
|
async function waitForNewCheckpointLine(): Promise<IteratorResult<CheckpointAndLine>> {
|
|
@@ -128,9 +137,22 @@ async function* streamResponseInner(
|
|
|
128
137
|
if (next.done) {
|
|
129
138
|
return { done: true, value: undefined };
|
|
130
139
|
}
|
|
140
|
+
const cp = next.value.base;
|
|
131
141
|
|
|
132
|
-
const
|
|
133
|
-
|
|
142
|
+
const tracer = new PerformanceTracer<SyncCheckpointTraceCategory>(`sync checkpoint ${cp.checkpoint}`);
|
|
143
|
+
const trace = {
|
|
144
|
+
tracer,
|
|
145
|
+
span: tracer.span('checkpoint')
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const line = await checksumState.buildNextCheckpointLine(next.value, trace.tracer);
|
|
150
|
+
return { done: false, value: { checkpoint: cp, line, trace: line == null ? null : trace } };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// Only end the span if we error. If we return normally, we pass ownership on to the caller.
|
|
153
|
+
trace.span.end();
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
134
156
|
}
|
|
135
157
|
|
|
136
158
|
try {
|
|
@@ -155,12 +177,18 @@ async function* streamResponseInner(
|
|
|
155
177
|
// No update to sync
|
|
156
178
|
continue;
|
|
157
179
|
}
|
|
180
|
+
const trace = next.value.value.trace!;
|
|
181
|
+
const checkpoint = next.value.value.checkpoint;
|
|
182
|
+
const tracer = trace.tracer;
|
|
158
183
|
|
|
159
|
-
const { checkpointLine, bucketsToFetch } = line;
|
|
184
|
+
const { checkpointLine, bucketsToFetch, bucketDataRequestHint } = line;
|
|
160
185
|
|
|
161
186
|
// Since yielding can block, we update the state just before yielding the line.
|
|
162
187
|
line.advance();
|
|
163
|
-
|
|
188
|
+
{
|
|
189
|
+
using _ = tracer.span('sending', 'checkpoint');
|
|
190
|
+
yield checkpointLine;
|
|
191
|
+
}
|
|
164
192
|
|
|
165
193
|
// Start syncing data for buckets up to the checkpoint. As soon as we have completed at least one priority and
|
|
166
194
|
// at least 1000 operations, we also start listening for new checkpoints concurrently. When a new checkpoint comes
|
|
@@ -169,6 +197,8 @@ async function* streamResponseInner(
|
|
|
169
197
|
const abortCheckpointController = new AbortController();
|
|
170
198
|
let syncedOperations = 0;
|
|
171
199
|
|
|
200
|
+
let checkpointResult: CheckpointResult | null = null;
|
|
201
|
+
|
|
172
202
|
const abortCheckpointSignal = AbortSignal.any([abortCheckpointController.signal, signal]);
|
|
173
203
|
|
|
174
204
|
const bucketsByPriority = [...Map.groupBy(bucketsToFetch, (bucket) => bucket.priority).entries()];
|
|
@@ -190,6 +220,11 @@ async function* streamResponseInner(
|
|
|
190
220
|
while (true) {
|
|
191
221
|
const next = await settledPromise(waitForNewCheckpointLine());
|
|
192
222
|
if (next.status == 'rejected') {
|
|
223
|
+
if (next.reason instanceof AbortError) {
|
|
224
|
+
checkpointResult = { result: 'invalidated', invalidationReason: 'checkpoint_cancelled' };
|
|
225
|
+
} else {
|
|
226
|
+
checkpointResult = { result: 'invalidated', invalidationReason: 'checkpoint_error' };
|
|
227
|
+
}
|
|
193
228
|
abortCheckpointController.abort();
|
|
194
229
|
} else if (!next.value.done) {
|
|
195
230
|
if (next.value.value.line == null) {
|
|
@@ -200,6 +235,7 @@ async function* streamResponseInner(
|
|
|
200
235
|
|
|
201
236
|
// A new sync line can be emitted. Stop running the bucketDataInBatches() iterations, making the
|
|
202
237
|
// main flow reach the new checkpoint.
|
|
238
|
+
checkpointResult = { result: 'invalidated', invalidationReason: 'checkpoint_superseded' };
|
|
203
239
|
abortCheckpointController.abort();
|
|
204
240
|
}
|
|
205
241
|
|
|
@@ -215,6 +251,14 @@ async function* streamResponseInner(
|
|
|
215
251
|
maybeRaceForNewCheckpoint();
|
|
216
252
|
}
|
|
217
253
|
|
|
254
|
+
function checkpointLogDetails(cp: storage.ReplicationCheckpoint) {
|
|
255
|
+
return {
|
|
256
|
+
checkpoint: cp.checkpoint,
|
|
257
|
+
user_id: tokenPayload.userIdJson,
|
|
258
|
+
...tracker.getIncrementalCheckpointStats()
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
218
262
|
// This incrementally updates dataBuckets with each individual bucket position.
|
|
219
263
|
// At the end of this, we can be sure that all buckets have data up to the checkpoint.
|
|
220
264
|
for (const [priority, buckets] of priorityBatches) {
|
|
@@ -223,11 +267,12 @@ async function* streamResponseInner(
|
|
|
223
267
|
break;
|
|
224
268
|
}
|
|
225
269
|
|
|
226
|
-
yield* bucketDataInBatches({
|
|
270
|
+
const batchResult = yield* bucketDataInBatches({
|
|
227
271
|
syncContext: syncContext,
|
|
228
272
|
bucketStorage: bucketStorage,
|
|
229
|
-
checkpoint
|
|
273
|
+
checkpoint,
|
|
230
274
|
bucketsToFetch: buckets,
|
|
275
|
+
requestHint: bucketDataRequestHint,
|
|
231
276
|
checkpointLine: line,
|
|
232
277
|
legacyDataLines: !isEncodingAsBson && params.raw_data != true,
|
|
233
278
|
onRowsSent: markOperationsSent,
|
|
@@ -237,7 +282,35 @@ async function* streamResponseInner(
|
|
|
237
282
|
// Passing null here will emit a full sync complete message at the end. If we pass a priority, we'll emit a partial
|
|
238
283
|
// sync complete message instead.
|
|
239
284
|
forPriority: !isLast ? priority : null,
|
|
240
|
-
logger
|
|
285
|
+
logger,
|
|
286
|
+
tracer
|
|
287
|
+
});
|
|
288
|
+
if (batchResult?.result == 'partial_checkpoint_complete') {
|
|
289
|
+
// We don't specifically include timing details here, since that would end the span.
|
|
290
|
+
// Timings are included in the checkpoint_complete following this.
|
|
291
|
+
logger.info(`partial_checkpoint_complete: ${checkpoint.checkpoint}`, {
|
|
292
|
+
priority,
|
|
293
|
+
...checkpointLogDetails(checkpoint)
|
|
294
|
+
});
|
|
295
|
+
} else if (batchResult?.result != null) {
|
|
296
|
+
checkpointResult = batchResult;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (checkpointResult?.result == 'checkpoint_complete') {
|
|
302
|
+
logger.info(`checkpoint_complete: ${checkpoint.checkpoint}`, {
|
|
303
|
+
// Incremental stats since the last checkpoint_complete, partial_checkpoint_complete or checkpoint_invalidated
|
|
304
|
+
...checkpointLogDetails(checkpoint),
|
|
305
|
+
// Timings since the last checkpoint or checkpoint_diff
|
|
306
|
+
ms: getCheckpointTraceTimings(trace.span)
|
|
307
|
+
});
|
|
308
|
+
} else if (checkpointResult?.result == 'invalidated') {
|
|
309
|
+
// A new checkpoint and checkpoint_complete should follow soon, but we log the stats already
|
|
310
|
+
logger.info(`checkpoint_invalidated: ${checkpoint.checkpoint}`, {
|
|
311
|
+
reason: checkpointResult.invalidationReason,
|
|
312
|
+
...checkpointLogDetails(checkpoint),
|
|
313
|
+
ms: getCheckpointTraceTimings(trace.span)
|
|
241
314
|
});
|
|
242
315
|
}
|
|
243
316
|
|
|
@@ -253,7 +326,8 @@ async function* streamResponseInner(
|
|
|
253
326
|
interface BucketDataRequest {
|
|
254
327
|
syncContext: SyncContext;
|
|
255
328
|
bucketStorage: storage.SyncRulesBucketStorage;
|
|
256
|
-
checkpoint:
|
|
329
|
+
checkpoint: storage.ReplicationCheckpoint;
|
|
330
|
+
requestHint: storage.BucketRequestHint;
|
|
257
331
|
/** Contains current bucket state. Modified by the request as data is sent. */
|
|
258
332
|
checkpointLine: CheckpointLine;
|
|
259
333
|
/** Subset of checkpointLine.bucketsToFetch, filtered by priority. */
|
|
@@ -272,9 +346,20 @@ interface BucketDataRequest {
|
|
|
272
346
|
forPriority: BucketPriority | null;
|
|
273
347
|
onRowsSent: (stats: OperationsSentStats) => void;
|
|
274
348
|
logger: Logger;
|
|
349
|
+
tracer: PerformanceTracer<SyncCheckpointTraceCategory>;
|
|
275
350
|
}
|
|
276
351
|
|
|
277
|
-
|
|
352
|
+
type CheckpointResult =
|
|
353
|
+
| { result: 'partial_checkpoint_complete' }
|
|
354
|
+
| { result: 'checkpoint_complete' }
|
|
355
|
+
| {
|
|
356
|
+
result: 'invalidated';
|
|
357
|
+
invalidationReason: 'compacted' | 'checkpoint_error' | 'checkpoint_superseded' | 'checkpoint_cancelled';
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
async function* bucketDataInBatches(
|
|
361
|
+
request: BucketDataRequest
|
|
362
|
+
): AsyncGenerator<util.StreamingSyncLine | string | null, CheckpointResult | null> {
|
|
278
363
|
let isDone = false;
|
|
279
364
|
while (!request.abort_batch.aborted && !isDone) {
|
|
280
365
|
// The code below is functionally the same as this for-await loop below.
|
|
@@ -291,9 +376,13 @@ async function* bucketDataInBatches(request: BucketDataRequest) {
|
|
|
291
376
|
while (true) {
|
|
292
377
|
const { value, done: iterDone } = await iter.next();
|
|
293
378
|
if (iterDone) {
|
|
379
|
+
if (value != null) {
|
|
380
|
+
return value;
|
|
381
|
+
}
|
|
294
382
|
break;
|
|
295
383
|
} else {
|
|
296
384
|
const { done, data } = value;
|
|
385
|
+
using _ = request.tracer.span('sending', 'data');
|
|
297
386
|
yield data;
|
|
298
387
|
if (done) {
|
|
299
388
|
isDone = true;
|
|
@@ -301,9 +390,10 @@ async function* bucketDataInBatches(request: BucketDataRequest) {
|
|
|
301
390
|
}
|
|
302
391
|
}
|
|
303
392
|
} finally {
|
|
304
|
-
await iter.return();
|
|
393
|
+
await iter.return(null);
|
|
305
394
|
}
|
|
306
395
|
}
|
|
396
|
+
return null;
|
|
307
397
|
}
|
|
308
398
|
|
|
309
399
|
interface BucketDataBatchResult {
|
|
@@ -314,11 +404,14 @@ interface BucketDataBatchResult {
|
|
|
314
404
|
/**
|
|
315
405
|
* Extracted as a separate internal function just to avoid memory leaks.
|
|
316
406
|
*/
|
|
317
|
-
async function* bucketDataBatch(
|
|
407
|
+
async function* bucketDataBatch(
|
|
408
|
+
request: BucketDataRequest
|
|
409
|
+
): AsyncGenerator<BucketDataBatchResult, CheckpointResult | null> {
|
|
318
410
|
const {
|
|
319
411
|
syncContext,
|
|
320
412
|
bucketStorage: storage,
|
|
321
413
|
checkpoint,
|
|
414
|
+
requestHint,
|
|
322
415
|
bucketsToFetch,
|
|
323
416
|
checkpointLine,
|
|
324
417
|
legacyDataLines,
|
|
@@ -328,42 +421,34 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
|
|
|
328
421
|
logger
|
|
329
422
|
} = request;
|
|
330
423
|
|
|
424
|
+
const tracer = request.tracer;
|
|
425
|
+
|
|
331
426
|
let checkpointInvalidated = false;
|
|
332
427
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
const acquired = await acquireSemaphoreAbortable(syncContext.syncSemaphore, AbortSignal.any([abort_batch]));
|
|
428
|
+
const acquired = await tracer.span('acquiring_lock').with(async () => {
|
|
429
|
+
return acquireSemaphoreAbortable(syncContext.syncSemaphore, AbortSignal.any([abort_batch]));
|
|
430
|
+
});
|
|
337
431
|
if (acquired === 'aborted') {
|
|
338
|
-
return;
|
|
432
|
+
return null;
|
|
339
433
|
}
|
|
340
434
|
|
|
341
|
-
const [
|
|
435
|
+
const [, release] = acquired;
|
|
342
436
|
try {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// concurrency limit.
|
|
346
|
-
logger.info(`Got sync lock. Slots available: ${value - 1}`, {
|
|
347
|
-
user_id: request.userIdForLogs,
|
|
348
|
-
sync_data_slots: value - 1
|
|
349
|
-
});
|
|
350
|
-
}
|
|
437
|
+
using _ = tracer.span('bucket_data');
|
|
438
|
+
let has_more = false;
|
|
351
439
|
// Optimization: Only fetch buckets for which the checksums have changed since the last checkpoint
|
|
352
440
|
// For the first batch, this will be all buckets.
|
|
353
441
|
const filteredBuckets = checkpointLine.getFilteredBucketPositions(bucketsToFetch);
|
|
354
|
-
const dataBatches = storage.getBucketDataBatch(checkpoint, filteredBuckets);
|
|
355
|
-
|
|
356
|
-
let has_more = false;
|
|
357
|
-
|
|
442
|
+
const dataBatches = storage.getBucketDataBatch(checkpoint, filteredBuckets, { requestHint });
|
|
358
443
|
for await (let { chunkData: r, targetOp } of dataBatches) {
|
|
359
444
|
// Abort in current batch if the connection is closed
|
|
360
445
|
if (abort_connection.aborted) {
|
|
361
|
-
return;
|
|
446
|
+
return null;
|
|
362
447
|
}
|
|
363
448
|
if (r.has_more) {
|
|
364
449
|
has_more = true;
|
|
365
450
|
}
|
|
366
|
-
if (targetOp != null && targetOp > checkpoint) {
|
|
451
|
+
if (targetOp != null && targetOp > checkpoint.checkpoint) {
|
|
367
452
|
checkpointInvalidated = true;
|
|
368
453
|
}
|
|
369
454
|
if (r.data.length == 0) {
|
|
@@ -371,7 +456,11 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
|
|
|
371
456
|
// storage can emit an empty chunk for a bucket that has no operations in the
|
|
372
457
|
// requested range, and its position must advance so the bucket is not
|
|
373
458
|
// re-requested indefinitely.
|
|
374
|
-
checkpointLine.updateBucketPosition({
|
|
459
|
+
checkpointLine.updateBucketPosition({
|
|
460
|
+
bucket: r.bucket,
|
|
461
|
+
nextAfter: BigInt(r.next_after),
|
|
462
|
+
hasMore: r.has_more
|
|
463
|
+
});
|
|
375
464
|
continue;
|
|
376
465
|
}
|
|
377
466
|
logger.debug(`Sending data for ${r.bucket}`);
|
|
@@ -398,7 +487,7 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
|
|
|
398
487
|
// Check if syncing bucket data is supposed to stop before fetching more data
|
|
399
488
|
// from storage.
|
|
400
489
|
if (abort_batch.aborted) {
|
|
401
|
-
return;
|
|
490
|
+
return null;
|
|
402
491
|
}
|
|
403
492
|
}
|
|
404
493
|
|
|
@@ -408,35 +497,50 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
|
|
|
408
497
|
// Don't send the checkpoint_complete line in this case.
|
|
409
498
|
// More data should be available immediately for a new checkpoint.
|
|
410
499
|
yield { data: null, done: true };
|
|
500
|
+
return { result: 'invalidated', invalidationReason: 'compacted' };
|
|
411
501
|
} else {
|
|
412
502
|
if (request.forPriority != null) {
|
|
413
503
|
const line: util.StreamingSyncCheckpointPartiallyComplete = {
|
|
414
504
|
partial_checkpoint_complete: {
|
|
415
|
-
last_op_id: util.internalToExternalOpId(checkpoint),
|
|
505
|
+
last_op_id: util.internalToExternalOpId(checkpoint.checkpoint),
|
|
416
506
|
priority: request.forPriority
|
|
417
507
|
}
|
|
418
508
|
};
|
|
419
509
|
yield { data: line, done: true };
|
|
510
|
+
return { result: 'partial_checkpoint_complete' };
|
|
420
511
|
} else {
|
|
421
512
|
const line: util.StreamingSyncCheckpointComplete = {
|
|
422
513
|
checkpoint_complete: {
|
|
423
|
-
last_op_id: util.internalToExternalOpId(checkpoint)
|
|
514
|
+
last_op_id: util.internalToExternalOpId(checkpoint.checkpoint)
|
|
424
515
|
}
|
|
425
516
|
};
|
|
426
517
|
yield { data: line, done: true };
|
|
518
|
+
return { result: 'checkpoint_complete' };
|
|
427
519
|
}
|
|
428
520
|
}
|
|
429
521
|
}
|
|
430
522
|
} finally {
|
|
431
|
-
if (value <= 3) {
|
|
432
|
-
// This can be noisy, so we only log when we get close to the
|
|
433
|
-
// concurrency limit.
|
|
434
|
-
logger.info(`Releasing sync lock`, {
|
|
435
|
-
user_id: request.userIdForLogs
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
523
|
release();
|
|
439
524
|
}
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function getCheckpointTraceTimings(span: Span): CheckpointTiming {
|
|
529
|
+
const timings = span.end();
|
|
530
|
+
const result: CheckpointTiming = {};
|
|
531
|
+
for (let key in timings) {
|
|
532
|
+
addTiming(result, key, timings[key]);
|
|
533
|
+
}
|
|
534
|
+
addTiming(result, 'other', span.selfDuration);
|
|
535
|
+
addTiming(result, 'total', span.endAt - span.startAt);
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function addTiming(timings: CheckpointTiming, key: string, durationMicros: number) {
|
|
540
|
+
const ms = Math.round(durationMicros / 1000);
|
|
541
|
+
if (ms > 0) {
|
|
542
|
+
timings[key] = (timings[key] ?? 0) + ms;
|
|
543
|
+
}
|
|
440
544
|
}
|
|
441
545
|
|
|
442
546
|
function transformLegacyResponse(bucketData: util.SyncBucketData): util.SyncBucketData<util.ProtocolOplogData> {
|
|
@@ -19,6 +19,7 @@ export interface ServiceContext {
|
|
|
19
19
|
storageEngine: storage.StorageEngine;
|
|
20
20
|
migrations: PowerSyncMigrationManager;
|
|
21
21
|
syncContext: SyncContext;
|
|
22
|
+
writeCheckpointBatcher: utils.WriteCheckpointBatcher;
|
|
22
23
|
serviceMode: ServiceContextMode;
|
|
23
24
|
eventsEngine: EventsEngine;
|
|
24
25
|
}
|
|
@@ -50,6 +51,7 @@ export class ServiceContextContainer implements ServiceContext {
|
|
|
50
51
|
eventsEngine: EventsEngine;
|
|
51
52
|
syncContext: SyncContext;
|
|
52
53
|
routerEngine: routes.RouterEngine;
|
|
54
|
+
writeCheckpointBatcher: utils.WriteCheckpointBatcher;
|
|
53
55
|
serviceMode: ServiceContextMode;
|
|
54
56
|
|
|
55
57
|
constructor(options: ServiceContextOptions) {
|
|
@@ -84,6 +86,11 @@ export class ServiceContextContainer implements ServiceContext {
|
|
|
84
86
|
stop: (routerEngine) => routerEngine.shutDown()
|
|
85
87
|
});
|
|
86
88
|
|
|
89
|
+
this.writeCheckpointBatcher = new utils.WriteCheckpointBatcher(
|
|
90
|
+
() => this.routerEngine.getAPI(),
|
|
91
|
+
() => this.storageEngine.activeBucketStorage
|
|
92
|
+
);
|
|
93
|
+
|
|
87
94
|
this.syncContext = new SyncContext({
|
|
88
95
|
maxDataFetchConcurrency: configuration.api_parameters.max_data_fetch_concurrency,
|
|
89
96
|
maxBuckets: configuration.api_parameters.max_buckets_per_connection,
|
|
@@ -8,10 +8,13 @@ export interface Span extends Disposable {
|
|
|
8
8
|
startAt: number;
|
|
9
9
|
/**
|
|
10
10
|
* End time in microseconds since an arbitrary epoch.
|
|
11
|
+
*
|
|
12
|
+
* 0 if the span hasn't ended yet.
|
|
11
13
|
*/
|
|
12
14
|
endAt: number;
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
|
-
* Time spent not in nested spans.
|
|
17
|
+
* Time spent not in nested spans, in microseconds.
|
|
15
18
|
*/
|
|
16
19
|
selfDuration: number;
|
|
17
20
|
|
|
@@ -36,6 +39,11 @@ export interface Span extends Disposable {
|
|
|
36
39
|
* Returns an aggregate record of category -> "selfDuration", in microseconds.
|
|
37
40
|
*/
|
|
38
41
|
end(): Record<string, number>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Call a nested function, then end the span, returning the function's result.
|
|
45
|
+
*/
|
|
46
|
+
with<T>(cb: () => Promise<T>): Promise<T>;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
function now() {
|
|
@@ -138,8 +146,17 @@ export class PerformanceTracer<K extends string> {
|
|
|
138
146
|
get durationMillis() {
|
|
139
147
|
return Math.ceil((this.endAt - this.startAt) / 1000);
|
|
140
148
|
},
|
|
149
|
+
|
|
141
150
|
[Symbol.dispose]() {
|
|
142
151
|
this.end();
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async with<T>(cb: () => Promise<T>): Promise<T> {
|
|
155
|
+
try {
|
|
156
|
+
return await cb();
|
|
157
|
+
} finally {
|
|
158
|
+
this.end();
|
|
159
|
+
}
|
|
143
160
|
}
|
|
144
161
|
};
|
|
145
162
|
this.stack.push(s);
|
|
@@ -1,33 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { RouteAPI } from '../api/RouteAPI.js';
|
|
3
|
-
import { BucketStorageFactory } from '../storage/storage-index.js';
|
|
1
|
+
import { WriteCheckpointBatcher } from './write-checkpoint-batcher.js';
|
|
4
2
|
|
|
5
3
|
export interface CreateWriteCheckpointOptions {
|
|
6
4
|
userId: string | undefined;
|
|
7
5
|
clientId: string | undefined;
|
|
8
|
-
|
|
9
|
-
storage: BucketStorageFactory;
|
|
6
|
+
batcher: WriteCheckpointBatcher;
|
|
10
7
|
}
|
|
11
8
|
export async function createWriteCheckpoint(options: CreateWriteCheckpointOptions) {
|
|
12
9
|
const full_user_id = checkpointUserId(options.userId, options.clientId);
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
if (!syncBucketStorage) {
|
|
16
|
-
throw new ServiceError(ErrorCode.PSYNC_S2302, `Cannot create Write Checkpoint since no sync config is active.`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const { writeCheckpoint, currentCheckpoint } = await options.api.createReplicationHead(async (currentCheckpoint) => {
|
|
20
|
-
const writeCheckpoint = await syncBucketStorage.createManagedWriteCheckpoint({
|
|
21
|
-
user_id: full_user_id,
|
|
22
|
-
heads: { '1': currentCheckpoint }
|
|
23
|
-
});
|
|
24
|
-
return { writeCheckpoint, currentCheckpoint };
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
writeCheckpoint: String(writeCheckpoint),
|
|
29
|
-
replicationHead: currentCheckpoint
|
|
30
|
-
};
|
|
11
|
+
return options.batcher.enqueue(full_user_id);
|
|
31
12
|
}
|
|
32
13
|
|
|
33
14
|
export function checkpointUserId(user_id: string | undefined, client_id: string | undefined) {
|
|
@@ -6,6 +6,7 @@ import { Base64ConfigCollector } from './collectors/impl/base64-config-collector
|
|
|
6
6
|
import { FallbackConfigCollector } from './collectors/impl/fallback-config-collector.js';
|
|
7
7
|
import { FileSystemConfigCollector } from './collectors/impl/filesystem-config-collector.js';
|
|
8
8
|
import {
|
|
9
|
+
DEFAULT_CHECKSUM_CACHE_TTL_MINUTES,
|
|
9
10
|
DEFAULT_MAX_BUCKETS_PER_CONNECTION,
|
|
10
11
|
DEFAULT_MAX_CONCURRENT_CONNECTIONS,
|
|
11
12
|
DEFAULT_MAX_DATA_FETCH_CONCURRENCY,
|
|
@@ -193,7 +194,10 @@ export class CompoundConfigCollector {
|
|
|
193
194
|
max_concurrent_connections:
|
|
194
195
|
baseConfig.api?.parameters?.max_concurrent_connections ?? DEFAULT_MAX_CONCURRENT_CONNECTIONS,
|
|
195
196
|
max_data_fetch_concurrency:
|
|
196
|
-
baseConfig.api?.parameters?.max_data_fetch_concurrency ?? DEFAULT_MAX_DATA_FETCH_CONCURRENCY
|
|
197
|
+
baseConfig.api?.parameters?.max_data_fetch_concurrency ?? DEFAULT_MAX_DATA_FETCH_CONCURRENCY,
|
|
198
|
+
bucket_count_cache_ttl_minutes: normalizeChecksumCacheTtlMinutes(
|
|
199
|
+
baseConfig.api?.parameters?.bucket_count_cache_ttl_minutes
|
|
200
|
+
)
|
|
197
201
|
},
|
|
198
202
|
// TODO maybe move this out of the connection or something
|
|
199
203
|
slot_name_prefix: baseConfig.replication?.connections?.[0]?.slot_name_prefix ?? 'powersync_',
|
|
@@ -249,3 +253,11 @@ export class CompoundConfigCollector {
|
|
|
249
253
|
};
|
|
250
254
|
}
|
|
251
255
|
}
|
|
256
|
+
|
|
257
|
+
function normalizeChecksumCacheTtlMinutes(ttlMinutes: number | undefined): number {
|
|
258
|
+
const normalized = ttlMinutes ?? DEFAULT_CHECKSUM_CACHE_TTL_MINUTES;
|
|
259
|
+
if (!Number.isFinite(normalized) || !Number.isInteger(normalized) || normalized < 1) {
|
|
260
|
+
throw new Error('api.parameters.bucket_count_cache_ttl_minutes must be a positive integer');
|
|
261
|
+
}
|
|
262
|
+
return normalized;
|
|
263
|
+
}
|
|
@@ -3,3 +3,4 @@ export const DEFAULT_MAX_CONCURRENT_CONNECTIONS = 200;
|
|
|
3
3
|
export const DEFAULT_MAX_DATA_FETCH_CONCURRENCY = 10;
|
|
4
4
|
export const DEFAULT_MAX_BUCKETS_PER_CONNECTION = 1000;
|
|
5
5
|
export const DEFAULT_MAX_PARAMETER_QUERY_RESULTS = 1000;
|
|
6
|
+
export const DEFAULT_CHECKSUM_CACHE_TTL_MINUTES = 1 * 60;
|
package/src/util/config/types.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type ResolvedPowerSyncConfig = {
|
|
|
53
53
|
max_data_fetch_concurrency: number;
|
|
54
54
|
max_buckets_per_connection: number;
|
|
55
55
|
max_parameter_query_results: number;
|
|
56
|
+
bucket_count_cache_ttl_minutes: number;
|
|
56
57
|
};
|
|
57
58
|
|
|
58
59
|
/** Prefix for postgres replication slot names, as well as replication stream names. */
|
package/src/util/util-index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './protocol-types.js';
|
|
|
8
8
|
export * from './secs.js';
|
|
9
9
|
export * from './utils.js';
|
|
10
10
|
export * from './version.js';
|
|
11
|
+
export * from './write-checkpoint-batcher.js';
|
|
11
12
|
|
|
12
13
|
export * from './config.js';
|
|
13
14
|
export * from './config/compound-config-collector.js';
|
package/src/util/utils.ts
CHANGED
|
@@ -223,7 +223,7 @@ export function isCompleteRow<V>(
|
|
|
223
223
|
* All other operations are replaced with a single CLEAR operation,
|
|
224
224
|
* summing their checksums, and using a 0 as an op_id.
|
|
225
225
|
*
|
|
226
|
-
* This is the function $r(B)$, as described in /docs/bucket-properties.md.
|
|
226
|
+
* This is the function $r(B)$, as described in /docs/storage/bucket-properties.md.
|
|
227
227
|
*
|
|
228
228
|
* Used for tests.
|
|
229
229
|
*/
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ErrorCode, ServiceAssertionError, ServiceError } from '@powersync/lib-services-framework';
|
|
2
|
+
import { RouteAPI } from '../api/RouteAPI.js';
|
|
3
|
+
import { BucketStorageFactory, SyncRulesBucketStorage } from '../storage/storage-index.js';
|
|
4
|
+
|
|
5
|
+
// Keep up to three source-head/storage batches executing concurrently under load.
|
|
6
|
+
// There is intentionally no explicit batch-size cap here: the HTTP request queue
|
|
7
|
+
// already bounds how many requests can be waiting in this process.
|
|
8
|
+
// Smaller values here have better efficiency, while larger numbers here may give slight improvements in latency.
|
|
9
|
+
// We want to limit the number of database connections in use for write checkpoints, so we keep the numbers low here.
|
|
10
|
+
const MAX_IN_FLIGHT_WRITE_CHECKPOINT_BATCHES = 3;
|
|
11
|
+
|
|
12
|
+
export interface CreateWriteCheckpointResult {
|
|
13
|
+
writeCheckpoint: string;
|
|
14
|
+
replicationHead: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface QueuedWriteCheckpoint {
|
|
18
|
+
userId: string;
|
|
19
|
+
resolvers: PromiseWithResolvers<CreateWriteCheckpointResult>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class WriteCheckpointBatcher {
|
|
23
|
+
private pending: QueuedWriteCheckpoint[] = [];
|
|
24
|
+
private inFlight = 0;
|
|
25
|
+
private scheduledPump: NodeJS.Timeout | undefined;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly getAPI: () => RouteAPI,
|
|
29
|
+
private readonly getStorage: () => BucketStorageFactory
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
enqueue(userId: string): Promise<CreateWriteCheckpointResult> {
|
|
33
|
+
const resolvers = Promise.withResolvers<CreateWriteCheckpointResult>();
|
|
34
|
+
this.pending.push({ userId, resolvers });
|
|
35
|
+
this.schedulePump();
|
|
36
|
+
return resolvers.promise;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private schedulePump() {
|
|
40
|
+
if (this.scheduledPump != null) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Delay dispatch by one timer turn so requests arriving together can share
|
|
45
|
+
// one source head and one storage write batch.
|
|
46
|
+
// While normally we wouldn't have http requests arriving at the exact same time, this is relevant when
|
|
47
|
+
// requests are queued by fastify: All current requests are typically batched together, and finish at the
|
|
48
|
+
// same time. Then we get many requests from the queue arriving at the same time. Without this delayed dispatch,
|
|
49
|
+
// the first request would form its own batch, and the rest will then wait for a new batch to become available.
|
|
50
|
+
// The delayed dispatch allow all those requests to be batched together.
|
|
51
|
+
this.scheduledPump = setTimeout(() => {
|
|
52
|
+
this.scheduledPump = undefined;
|
|
53
|
+
this.pump();
|
|
54
|
+
}, 0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private pump() {
|
|
58
|
+
while (this.inFlight < MAX_IN_FLIGHT_WRITE_CHECKPOINT_BATCHES && this.pending.length > 0) {
|
|
59
|
+
const batch = this.pending;
|
|
60
|
+
this.pending = [];
|
|
61
|
+
this.inFlight++;
|
|
62
|
+
|
|
63
|
+
void this.executeBatch(batch).finally(() => {
|
|
64
|
+
this.inFlight--;
|
|
65
|
+
this.schedulePump();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async executeBatch(batch: QueuedWriteCheckpoint[]) {
|
|
71
|
+
try {
|
|
72
|
+
const syncBucketStorage = (await this.getStorage().getActiveSyncConfig())?.storage;
|
|
73
|
+
if (!syncBucketStorage) {
|
|
74
|
+
throw new ServiceError(ErrorCode.PSYNC_S2302, `Cannot create Write Checkpoint since no sync config is active.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { writeCheckpoints, currentCheckpoint } = await this.getAPI().createReplicationHead(
|
|
78
|
+
async (currentCheckpoint) => {
|
|
79
|
+
const writeCheckpoints = await this.createBatchWriteCheckpoints(syncBucketStorage, batch, currentCheckpoint);
|
|
80
|
+
return { writeCheckpoints, currentCheckpoint };
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const results = batch.map((request) => {
|
|
85
|
+
const writeCheckpoint = writeCheckpoints.get(request.userId);
|
|
86
|
+
if (writeCheckpoint == null) {
|
|
87
|
+
throw new ServiceAssertionError(
|
|
88
|
+
`Write checkpoint storage did not return a checkpoint for user ${request.userId}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { request, writeCheckpoint };
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
for (const { request, writeCheckpoint } of results) {
|
|
96
|
+
request.resolvers.resolve({
|
|
97
|
+
writeCheckpoint: String(writeCheckpoint),
|
|
98
|
+
replicationHead: currentCheckpoint
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// Do not retry here. The route should pass failures through so clients can
|
|
103
|
+
// apply their normal write-checkpoint retry policy.
|
|
104
|
+
for (const request of batch) {
|
|
105
|
+
request.resolvers.reject(error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async createBatchWriteCheckpoints(
|
|
111
|
+
syncBucketStorage: SyncRulesBucketStorage,
|
|
112
|
+
batch: QueuedWriteCheckpoint[],
|
|
113
|
+
currentCheckpoint: string
|
|
114
|
+
) {
|
|
115
|
+
return syncBucketStorage.createManagedWriteCheckpoints(
|
|
116
|
+
batch.map((request) => ({
|
|
117
|
+
user_id: request.userId,
|
|
118
|
+
heads: { '1': currentCheckpoint }
|
|
119
|
+
}))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|