@powersync/service-core 1.23.0 → 1.23.2

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/api/RouteAPI.d.ts +25 -11
  3. package/dist/replication/AbstractReplicator.d.ts +20 -1
  4. package/dist/replication/AbstractReplicator.js +106 -47
  5. package/dist/replication/AbstractReplicator.js.map +1 -1
  6. package/dist/routes/RouterEngine.d.ts +1 -0
  7. package/dist/routes/RouterEngine.js +6 -1
  8. package/dist/routes/RouterEngine.js.map +1 -1
  9. package/dist/routes/configure-fastify.d.ts +52 -44
  10. package/dist/routes/configure-fastify.js +15 -2
  11. package/dist/routes/configure-fastify.js.map +1 -1
  12. package/dist/routes/endpoints/admin.js +2 -1
  13. package/dist/routes/endpoints/admin.js.map +1 -1
  14. package/dist/routes/endpoints/checkpointing.js +1 -3
  15. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  16. package/dist/storage/BucketStorageBatch.d.ts +60 -11
  17. package/dist/storage/BucketStorageBatch.js.map +1 -1
  18. package/dist/storage/ChecksumCache.d.ts +11 -6
  19. package/dist/storage/ChecksumCache.js +5 -4
  20. package/dist/storage/ChecksumCache.js.map +1 -1
  21. package/dist/storage/PersistedSyncConfigContent.d.ts +3 -0
  22. package/dist/storage/PersistedSyncConfigContent.js +2 -0
  23. package/dist/storage/PersistedSyncConfigContent.js.map +1 -1
  24. package/dist/storage/SyncRulesBucketStorage.d.ts +35 -5
  25. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  26. package/dist/storage/WriteCheckpointAPI.d.ts +1 -1
  27. package/dist/sync/BucketChecksumState.d.ts +5 -2
  28. package/dist/sync/BucketChecksumState.js +36 -8
  29. package/dist/sync/BucketChecksumState.js.map +1 -1
  30. package/dist/sync/RequestTracker.d.ts +7 -0
  31. package/dist/sync/RequestTracker.js +12 -0
  32. package/dist/sync/RequestTracker.js.map +1 -1
  33. package/dist/sync/sync.js +118 -38
  34. package/dist/sync/sync.js.map +1 -1
  35. package/dist/system/ServiceContext.d.ts +2 -0
  36. package/dist/system/ServiceContext.js +2 -0
  37. package/dist/system/ServiceContext.js.map +1 -1
  38. package/dist/tracing/PerformanceTracer.d.ts +7 -1
  39. package/dist/tracing/PerformanceTracer.js +8 -0
  40. package/dist/tracing/PerformanceTracer.js.map +1 -1
  41. package/dist/util/checkpointing.d.ts +3 -8
  42. package/dist/util/checkpointing.js +1 -16
  43. package/dist/util/checkpointing.js.map +1 -1
  44. package/dist/util/config/compound-config-collector.js +10 -2
  45. package/dist/util/config/compound-config-collector.js.map +1 -1
  46. package/dist/util/config/defaults.d.ts +1 -0
  47. package/dist/util/config/defaults.js +1 -0
  48. package/dist/util/config/defaults.js.map +1 -1
  49. package/dist/util/config/types.d.ts +1 -0
  50. package/dist/util/util-index.d.ts +1 -0
  51. package/dist/util/util-index.js +1 -0
  52. package/dist/util/util-index.js.map +1 -1
  53. package/dist/util/utils.d.ts +1 -1
  54. package/dist/util/utils.js +1 -1
  55. package/dist/util/write-checkpoint-batcher.d.ts +19 -0
  56. package/dist/util/write-checkpoint-batcher.js +90 -0
  57. package/dist/util/write-checkpoint-batcher.js.map +1 -0
  58. package/package.json +5 -5
  59. package/src/api/RouteAPI.ts +25 -11
  60. package/src/replication/AbstractReplicator.ts +134 -47
  61. package/src/routes/RouterEngine.ts +7 -1
  62. package/src/routes/configure-fastify.ts +25 -2
  63. package/src/routes/endpoints/admin.ts +2 -1
  64. package/src/routes/endpoints/checkpointing.ts +1 -4
  65. package/src/storage/BucketStorageBatch.ts +61 -11
  66. package/src/storage/ChecksumCache.ts +27 -14
  67. package/src/storage/PersistedSyncConfigContent.ts +4 -0
  68. package/src/storage/SyncRulesBucketStorage.ts +42 -5
  69. package/src/storage/WriteCheckpointAPI.ts +1 -1
  70. package/src/sync/BucketChecksumState.ts +65 -8
  71. package/src/sync/RequestTracker.ts +20 -0
  72. package/src/sync/sync.ts +148 -44
  73. package/src/system/ServiceContext.ts +7 -0
  74. package/src/tracing/PerformanceTracer.ts +18 -1
  75. package/src/util/checkpointing.ts +3 -22
  76. package/src/util/config/compound-config-collector.ts +13 -1
  77. package/src/util/config/defaults.ts +1 -0
  78. package/src/util/config/types.ts +1 -0
  79. package/src/util/util-index.ts +1 -0
  80. package/src/util/utils.ts +1 -1
  81. package/src/util/write-checkpoint-batcher.ts +122 -0
  82. package/test/src/checksum_cache.test.ts +22 -0
  83. package/test/src/config.test.ts +42 -0
  84. package/test/src/diagnostics.test.ts +13 -12
  85. package/test/src/routes/admin.test.ts +12 -10
  86. package/test/src/sync/BucketChecksumState.test.ts +41 -1
  87. package/test/src/util/checkpointing.test.ts +170 -0
  88. 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 { BucketChecksumState, CheckpointLine } from './BucketChecksumState.js';
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: bigint;
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 line = await checksumState.buildNextCheckpointLine(next.value);
133
- return { done: false, value: { checkpoint: next.value.base.checkpoint, line } };
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
- yield checkpointLine;
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: next.value.value.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: util.InternalOpId;
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
- async function* bucketDataInBatches(request: BucketDataRequest) {
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(request: BucketDataRequest): AsyncGenerator<BucketDataBatchResult, void> {
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
- if (syncContext.syncSemaphore.isLocked()) {
334
- logger.info('Sync concurrency limit reached, waiting for lock', { user_id: request.userIdForLogs });
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 [value, release] = acquired;
435
+ const [, release] = acquired;
342
436
  try {
343
- if (value <= 3) {
344
- // This can be noisy, so we only log when we get close to the
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({ bucket: r.bucket, nextAfter: BigInt(r.next_after), hasMore: r.has_more });
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 { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
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
- api: RouteAPI;
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
- const syncBucketStorage = (await options.storage.getActiveSyncConfig())?.storage;
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;
@@ -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. */
@@ -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
+ }