@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
@@ -1,6 +1,7 @@
1
1
  import { container, ErrorCode, logger } from '@powersync/lib-services-framework';
2
2
  import { ReplicationMetric } from '@powersync/service-types';
3
3
  import { hrtime } from 'node:process';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
4
5
  import winston from 'winston';
5
6
  import { MetricsEngine } from '../metrics/MetricsEngine.js';
6
7
  import * as storage from '../storage/storage-index.js';
@@ -13,6 +14,12 @@ import { ConnectionTestResult } from './ReplicationModule.js';
13
14
  // 1 minute
14
15
  const PING_INTERVAL = 1_000_000_000n * 60n;
15
16
 
17
+ // In the initial startup period, we use a short refresh interval. This helps to take over replication quickly in the case
18
+ // of rolling deploys. After the initial period, we switch to a longer refresh interval to reduce load.
19
+ const FAST_REFRESH_INTERVAL_MS = 500;
20
+ const FAST_REFRESH_TIMEOUT_MS = 120_000;
21
+ const REFRESH_INTERVAL_MS = 5_000;
22
+
16
23
  export interface CreateJobOptions {
17
24
  lock: storage.ReplicationLock;
18
25
  storage: storage.SyncRulesBucketStorage;
@@ -36,7 +43,7 @@ export interface AbstractReplicatorOptions {
36
43
  */
37
44
  export abstract class AbstractReplicator<T extends AbstractReplicationJob = AbstractReplicationJob> {
38
45
  protected logger: winston.Logger;
39
- private lockAlerted: boolean = false;
46
+ private lastReplicationStreamInfoLogs = new Map<string, string>();
40
47
  /**
41
48
  * Map of replication jobs by replication stream job id. Usually there is only one running job, but there could be two
42
49
  * when transitioning to a new replication stream.
@@ -65,6 +72,11 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
65
72
  this.logger = logger.child({ name: `Replicator:${options.id}` });
66
73
  }
67
74
 
75
+ /**
76
+ * Create the source-specific replication job for a locked storage stream.
77
+ *
78
+ * The job owns the per-stream snapshot and streaming work for this attempt.
79
+ */
68
80
  abstract createJob(options: CreateJobOptions): T;
69
81
 
70
82
  /**
@@ -131,17 +143,20 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
131
143
  }
132
144
 
133
145
  private async runLoop() {
134
- const syncRules = await this.syncRuleProvider.get();
146
+ const loadedSyncConfig = await this.syncRuleProvider.get();
135
147
 
136
148
  let configuredLock: storage.ReplicationLock | undefined = undefined;
137
- if (syncRules != null) {
149
+ if (loadedSyncConfig != null) {
138
150
  this.logger.info('Loaded sync config');
139
151
  try {
140
152
  // Configure new sync config, if they have changed.
141
153
  // In that case, also immediately take out a lock, so that another process doesn't start replication on it.
154
+ // This upfront lock is mostly superseded by the replicationStreamLoadedSyncConfigMatch() check. However, that doesn't cover old
155
+ // versions before that check was added, so we keep the lock for now - for where te service version and sync config is updated at
156
+ // the same time.
142
157
 
143
158
  const { lock } = await this.storage.configureSyncRules(
144
- storage.updateSyncRulesFromYaml(syncRules, { lock: true, validate: this.syncRuleProvider.exitOnError })
159
+ storage.updateSyncRulesFromYaml(loadedSyncConfig, { lock: true, validate: this.syncRuleProvider.exitOnError })
145
160
  );
146
161
  if (lock) {
147
162
  configuredLock = lock;
@@ -155,10 +170,15 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
155
170
  } else {
156
171
  this.logger.info('No sync streams or rules configured - configure via API');
157
172
  }
173
+ let useFastRefresh = true;
174
+ const fastRefreshDeadline = Date.now() + FAST_REFRESH_TIMEOUT_MS;
158
175
  while (!this.stopped) {
159
176
  await container.probes.touch();
160
177
  try {
161
- await this.refresh({ configured_lock: configuredLock });
178
+ const refreshResult = await this.refresh({ configuredLock, loadedSyncConfig });
179
+ if (refreshResult.replicationJobStarted || Date.now() >= fastRefreshDeadline) {
180
+ useFastRefresh = false;
181
+ }
162
182
  // The lock is only valid on the first refresh.
163
183
  configuredLock = undefined;
164
184
 
@@ -177,24 +197,40 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
177
197
  } catch (e) {
178
198
  this.logger.error('Failed to refresh replication jobs', e);
179
199
  }
180
- await new Promise((resolve) => setTimeout(resolve, 5000));
200
+ if (Date.now() >= fastRefreshDeadline) {
201
+ useFastRefresh = false;
202
+ }
203
+ await sleep(useFastRefresh ? FAST_REFRESH_INTERVAL_MS : REFRESH_INTERVAL_MS);
181
204
  }
182
205
  }
183
206
 
184
- private async refresh(options?: { configured_lock?: storage.ReplicationLock }) {
207
+ private async refresh(options?: {
208
+ configuredLock?: storage.ReplicationLock;
209
+ loadedSyncConfig?: string;
210
+ }): Promise<{ replicationJobStarted: boolean }> {
185
211
  if (this.stopped) {
186
- return;
212
+ return { replicationJobStarted: false };
187
213
  }
188
214
 
189
- let configuredLock = options?.configured_lock;
215
+ let configuredLock = options?.configuredLock;
216
+ let replicationJobStarted = false;
190
217
 
191
218
  const existingJobs = new Map<string, T>(this.replicationJobs.entries());
192
219
  const replicatingStreams = await this.storage.getReplicatingReplicationStreams();
193
220
  const newJobs = new Map<string, T>();
221
+ const streamsToStart: storage.PersistedReplicationStream[] = [];
194
222
  let activeJob: T | undefined = undefined;
195
223
  for (let replicationStream of replicatingStreams) {
196
224
  const jobId = replicationStream.replicationJobId;
197
225
  const existingJob = existingJobs.get(jobId);
226
+ const syncConfigMismatchMessage = 'Ignoring replication stream for sync config not loaded by this process';
227
+ if (!this.shouldHandleReplicationStream(replicationStream, options?.loadedSyncConfig)) {
228
+ this.logReplicationStreamInfoOnce(replicationStream, 'sync-config-mismatch', () => {
229
+ replicationStream.logger.info(syncConfigMismatchMessage);
230
+ });
231
+ continue;
232
+ }
233
+ this.clearReplicationStreamInfoLog(replicationStream, 'sync-config-mismatch');
198
234
  if (replicationStream.state == storage.SyncRuleState.ACTIVE && activeJob == null) {
199
235
  activeJob = existingJob;
200
236
  }
@@ -208,47 +244,12 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
208
244
  existingJobs.delete(jobId);
209
245
  } else {
210
246
  // New sync config was found (or resume after restart)
211
- try {
212
- let lock: storage.ReplicationLock;
213
- if (configuredLock?.sync_rules_id == replicationStream.replicationStreamId) {
214
- lock = configuredLock;
215
- } else {
216
- lock = await replicationStream.lock();
217
- }
218
- const syncRuleStorage = this.storage.getInstance(replicationStream);
219
- const newJob = this.createJob({
220
- lock: lock,
221
- storage: syncRuleStorage
222
- });
223
-
224
- newJobs.set(jobId, newJob);
225
- newJob.start();
226
- if (replicationStream.state == storage.SyncRuleState.ACTIVE) {
227
- activeJob = newJob;
228
- }
229
- this.lockAlerted = false;
230
- } catch (e) {
231
- if (e?.errorData?.code === ErrorCode.PSYNC_S1003) {
232
- if (!this.lockAlerted) {
233
- replicationStream.logger.info(`[${e.errorData.code}] ${e.errorData.description}`);
234
- this.lockAlerted = true;
235
- }
236
- } else {
237
- // Could be a sync config parse error,
238
- // for example from stricter validation that was added.
239
- // This will be retried every couple of seconds.
240
- // When new (valid) sync config is deployed and processed, this one be disabled.
241
- replicationStream.logger.error('Failed to start replication for new sync config', e);
242
- }
243
- }
247
+ streamsToStart.push(replicationStream);
244
248
  }
245
249
  }
246
250
 
247
- this.replicationJobs = newJobs;
248
- this.activeReplicationJob = activeJob;
249
-
250
- // Stop any orphaned jobs that no longer have a replication stream.
251
- // Termination happens below
251
+ // Stop any orphaned jobs that no longer have a replication stream before starting replacements.
252
+ // Termination happens below.
252
253
  for (let job of existingJobs.values()) {
253
254
  // Old - stop and clean up
254
255
  try {
@@ -259,6 +260,46 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
259
260
  }
260
261
  }
261
262
 
263
+ for (let replicationStream of streamsToStart) {
264
+ const jobId = replicationStream.replicationJobId;
265
+ try {
266
+ let lock: storage.ReplicationLock;
267
+ if (configuredLock?.sync_rules_id == replicationStream.replicationStreamId) {
268
+ lock = configuredLock;
269
+ } else {
270
+ lock = await replicationStream.lock();
271
+ }
272
+ const syncRuleStorage = this.storage.getInstance(replicationStream);
273
+ const newJob = this.createJob({
274
+ lock: lock,
275
+ storage: syncRuleStorage
276
+ });
277
+
278
+ newJobs.set(jobId, newJob);
279
+ newJob.start();
280
+ replicationJobStarted = true;
281
+ if (replicationStream.state == storage.SyncRuleState.ACTIVE) {
282
+ activeJob = newJob;
283
+ }
284
+ this.lastReplicationStreamInfoLogs.delete(replicationStream.replicationStreamName);
285
+ } catch (e) {
286
+ if (e?.errorData?.code === ErrorCode.PSYNC_S1003) {
287
+ this.logReplicationStreamInfoOnce(replicationStream, 'replication-stream-locked', () => {
288
+ replicationStream.logger.info(`[${e.errorData.code}] ${e.errorData.description}`);
289
+ });
290
+ } else {
291
+ // Could be a sync config parse error,
292
+ // for example from stricter validation that was added.
293
+ // This will be retried every couple of seconds.
294
+ // When new (valid) sync config is deployed and processed, this one be disabled.
295
+ replicationStream.logger.error('Failed to start replication for new sync config', e);
296
+ }
297
+ }
298
+ }
299
+
300
+ this.replicationJobs = newJobs;
301
+ this.activeReplicationJob = activeJob;
302
+
262
303
  // Replication stream stopped previously, including by a different process.
263
304
  const stopped = await this.storage.getStoppedReplicationStreams();
264
305
  for (let replicationStream of stopped) {
@@ -280,6 +321,49 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
280
321
  });
281
322
  this.clearingJobs.set(replicationStream.replicationStreamId, promise);
282
323
  }
324
+
325
+ return { replicationJobStarted };
326
+ }
327
+
328
+ private logReplicationStreamInfoOnce(
329
+ replicationStream: storage.PersistedReplicationStream,
330
+ category: string,
331
+ log: () => void
332
+ ) {
333
+ if (this.lastReplicationStreamInfoLogs.get(replicationStream.replicationStreamName) == category) {
334
+ return;
335
+ }
336
+
337
+ log();
338
+ this.lastReplicationStreamInfoLogs.set(replicationStream.replicationStreamName, category);
339
+ }
340
+
341
+ private clearReplicationStreamInfoLog(replicationStream: storage.PersistedReplicationStream, category: string) {
342
+ if (this.lastReplicationStreamInfoLogs.get(replicationStream.replicationStreamName) == category) {
343
+ this.lastReplicationStreamInfoLogs.delete(replicationStream.replicationStreamName);
344
+ }
345
+ }
346
+
347
+ /**
348
+ * When we load sync config from the filesystem/config source, we ignore any config loaded by other processes.
349
+ * The idea is that if a different process loads updated config, that process should process it, not this one.
350
+ *
351
+ * This specifically helps for cases with rolling deploys.
352
+ *
353
+ * This does not apply when sync config is loaded from the database/API, instead of in the config.
354
+ */
355
+ private shouldHandleReplicationStream(
356
+ replicationStream: storage.PersistedReplicationStream,
357
+ loadedSyncRules: string | undefined
358
+ ) {
359
+ if (loadedSyncRules == null) {
360
+ return true;
361
+ }
362
+
363
+ const processingConfig = replicationStream.syncConfigContent.find(
364
+ (syncConfig) => syncConfig.syncConfigState == storage.SyncRuleState.PROCESSING
365
+ );
366
+ return processingConfig == null || processingConfig.sync_rules_content == loadedSyncRules;
283
367
  }
284
368
 
285
369
  protected createJobId(syncRuleId: number) {
@@ -295,6 +379,9 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
295
379
  syncRuleStorage.logger.info(`Successfully terminated replication stream`);
296
380
  }
297
381
 
382
+ /**
383
+ * Check source connectivity using the replicator's decoded connection config.
384
+ */
298
385
  abstract testConnection(): Promise<ConnectionTestResult>;
299
386
 
300
387
  /**
@@ -11,6 +11,7 @@ export type RouterSetupResponse = {
11
11
 
12
12
  export type RouterEngineRoutes = {
13
13
  api_routes: RouteDefinition[];
14
+ checkpointing_routes: RouteDefinition[];
14
15
  stream_routes: RouteDefinition[];
15
16
  socket_routes: SocketRouteGenerator[];
16
17
  };
@@ -42,6 +43,7 @@ export class RouterEngine {
42
43
 
43
44
  this.routes = {
44
45
  api_routes: [],
46
+ checkpointing_routes: [],
45
47
  stream_routes: [],
46
48
  socket_routes: []
47
49
  };
@@ -49,13 +51,17 @@ export class RouterEngine {
49
51
 
50
52
  public registerRoutes(routes: Partial<RouterEngineRoutes>) {
51
53
  this.routes.api_routes.push(...(routes.api_routes ?? []));
54
+ this.routes.checkpointing_routes.push(...(routes.checkpointing_routes ?? []));
52
55
  this.routes.stream_routes.push(...(routes.stream_routes ?? []));
53
56
  this.routes.socket_routes.push(...(routes.socket_routes ?? []));
54
57
  }
55
58
 
56
59
  public get hasRoutes() {
57
60
  return (
58
- this.routes.api_routes.length > 0 || this.routes.stream_routes.length > 0 || this.routes.socket_routes.length > 0
61
+ this.routes.api_routes.length > 0 ||
62
+ this.routes.checkpointing_routes.length > 0 ||
63
+ this.routes.stream_routes.length > 0 ||
64
+ this.routes.socket_routes.length > 0
59
65
  );
60
66
  }
61
67
 
@@ -31,6 +31,7 @@ export type RouteRegistrationOptions = {
31
31
  */
32
32
  export type RouteDefinitions = {
33
33
  api?: Partial<RouteRegistrationOptions>;
34
+ checkpointing?: Partial<RouteRegistrationOptions>;
34
35
  sync_stream?: Partial<RouteRegistrationOptions>;
35
36
  };
36
37
 
@@ -41,12 +42,20 @@ export type FastifyServerConfig = {
41
42
 
42
43
  export const DEFAULT_ROUTE_OPTIONS = {
43
44
  api: {
44
- routes: [...ADMIN_ROUTES, ...CHECKPOINT_ROUTES, ...SYNC_RULES_ROUTES, ...PROBES_ROUTES],
45
+ routes: [...ADMIN_ROUTES, ...SYNC_RULES_ROUTES, ...PROBES_ROUTES],
45
46
  queue_options: {
46
47
  concurrency: 10,
47
48
  max_queue_depth: 20
48
49
  }
49
50
  },
51
+ checkpointing: {
52
+ routes: [...CHECKPOINT_ROUTES],
53
+ queue_options: {
54
+ // Note that the values here has an effect on WriteCheckpointBatcher
55
+ concurrency: 100,
56
+ max_queue_depth: 100
57
+ }
58
+ },
50
59
  sync_stream: {
51
60
  routes: [...SYNC_STREAM_ROUTES],
52
61
  queue_options: {
@@ -72,6 +81,7 @@ export function configureFastifyServer(server: fastify.FastifyInstance, options:
72
81
 
73
82
  // Set on the outer server so both child scopes inherit.
74
83
  registerFastifyErrorHandler(server);
84
+ registerFastifyNotFoundHandler(server);
75
85
 
76
86
  /**
77
87
  * Fastify creates an encapsulated context for each `.register` call.
@@ -81,7 +91,6 @@ export function configureFastifyServer(server: fastify.FastifyInstance, options:
81
91
  */
82
92
  server.register(async function (childContext) {
83
93
  registerFastifyRoutes(childContext, generateContext, routes.api?.routes ?? DEFAULT_ROUTE_OPTIONS.api.routes);
84
- registerFastifyNotFoundHandler(childContext);
85
94
 
86
95
  // Limit the active concurrent requests
87
96
  childContext.addHook(
@@ -90,6 +99,20 @@ export function configureFastifyServer(server: fastify.FastifyInstance, options:
90
99
  );
91
100
  });
92
101
 
102
+ server.register(async function (childContext) {
103
+ registerFastifyRoutes(
104
+ childContext,
105
+ generateContext,
106
+ routes.checkpointing?.routes ?? DEFAULT_ROUTE_OPTIONS.checkpointing.routes
107
+ );
108
+
109
+ // Limit the active concurrent requests
110
+ childContext.addHook(
111
+ 'onRequest',
112
+ createRequestQueueHook(routes.checkpointing?.queue_options ?? DEFAULT_ROUTE_OPTIONS.checkpointing.queue_options)
113
+ );
114
+ });
115
+
93
116
  // Create a separate context for concurrency queueing
94
117
  server.register(async function (childContext) {
95
118
  registerFastifyRoutes(
@@ -241,7 +241,8 @@ export const validate = routeDefinition({
241
241
  replicationStreamName: '',
242
242
  storageVersion: storage.LEGACY_STORAGE_VERSION,
243
243
  sync_rules_content: content,
244
- compiled_plan: null
244
+ compiled_plan: null,
245
+ syncConfigState: storage.SyncRuleState.PROCESSING
245
246
  });
246
247
 
247
248
  const connectionStatus = await apiHandler.getConnectionStatus();
@@ -54,13 +54,10 @@ export const writeCheckpoint2 = routeDefinition({
54
54
  handler: async (payload) => {
55
55
  const { token_payload, service_context } = payload.context;
56
56
 
57
- const apiHandler = service_context.routerEngine.getAPI();
58
-
59
57
  const { replicationHead, writeCheckpoint } = await util.createWriteCheckpoint({
60
58
  userId: token_payload!.userIdString,
61
59
  clientId: payload.params.client_id,
62
- api: apiHandler,
63
- storage: service_context.storageEngine.activeBucketStorage
60
+ batcher: service_context.writeCheckpointBatcher
64
61
  });
65
62
 
66
63
  logger.info(
@@ -12,6 +12,15 @@ export const DEFAULT_BUCKET_BATCH_COMMIT_OPTIONS: ResolvedBucketBatchCommitOptio
12
12
  oldestUncommittedChange: null
13
13
  };
14
14
 
15
+ /**
16
+ * Storage writer for a source-specific unit of replication work.
17
+ *
18
+ * A batch evaluates source row changes against the hydrated sync config,
19
+ * persists bucket operations and parameter lookup rows, tracks source table and
20
+ * snapshot state, records the committed source position for visible
21
+ * checkpoints, and stores restart cursors for sources with PowerSync-managed
22
+ * resume state.
23
+ */
15
24
  export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageListener>, AsyncDisposable {
16
25
  /**
17
26
  * Alias for [Symbol.asyncDispose]
@@ -29,21 +38,28 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
29
38
  readonly skipExistingRows: boolean;
30
39
 
31
40
  /**
32
- * Save an op, and potentially flush.
41
+ * Queue one normalized source row operation and potentially flush.
33
42
  *
34
- * This can be an insert, update or delete op.
43
+ * When flushed, the operation is evaluated against data and parameter
44
+ * queries. That may produce bucket operations, parameter lookup rows, and
45
+ * current-data updates. `save()` may trigger that flush automatically when
46
+ * the batch reaches its size limits, but a flush does not create a checkpoint
47
+ * by itself.
35
48
  */
36
49
  save(record: SaveOptions): Promise<FlushedResult | null>;
37
50
 
38
51
  /**
39
- * Replicate a truncate op - deletes all data in the specified tables.
52
+ * Replicate a truncate operation by removing all currently replicated rows
53
+ * for the specified source tables.
40
54
  */
41
55
  truncate(sourceTables: SourceTable[]): Promise<FlushedResult | null>;
42
56
 
43
57
  /**
44
- * Drop one or more tables.
58
+ * Drop one or more source table mappings.
45
59
  *
46
- * This is the same as truncate, but additionally removes the SourceTable record.
60
+ * This performs the same logical row removal as `truncate()`, then removes
61
+ * the stored `SourceTable` record. It is used for table drops, renames,
62
+ * replica identity changes, and superseded mappings.
47
63
  */
48
64
  drop(sourceTables: SourceTable[]): Promise<FlushedResult | null>;
49
65
 
@@ -58,21 +74,26 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
58
74
  flush(options?: BatchBucketFlushOptions): Promise<FlushedResult | null>;
59
75
 
60
76
  /**
61
- * Flush and commit any saved ops. This creates a new checkpoint by default.
77
+ * Flush saved ops and advance the committed source checkpoint position.
62
78
  *
63
- * Only call this after a transaction.
79
+ * Only call this at a source transaction, page, snapshot, or other boundary
80
+ * where it is valid for clients to observe all flushed changes together. A
81
+ * single commit may include multiple complete source transactions, but must
82
+ * not split one source transaction across checkpoint boundaries.
64
83
  */
65
84
  commit(lsn: string, options?: BucketBatchCommitOptions): Promise<CheckpointResult>;
66
85
 
67
86
  /**
68
- * Advance the checkpoint LSN position, without any associated op.
87
+ * Advance the committed source checkpoint position without associated row
88
+ * operations.
69
89
  *
70
- * This must only be called when not inside a transaction.
90
+ * Use this for source heartbeats, marker-only events, and idle-source write
91
+ * checkpoint progress. This must only be called outside a source transaction.
71
92
  */
72
93
  keepalive(lsn: string): Promise<CheckpointResult>;
73
94
 
74
95
  /**
75
- * Set the LSN that replication should resume from.
96
+ * Set the source position that replication should resume from after restart.
76
97
  *
77
98
  * This can be used for:
78
99
  * 1. Setting the LSN for a snapshot, before starting replication.
@@ -80,6 +101,10 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
80
101
  *
81
102
  * Not required if the source database keeps track of this, for example with
82
103
  * PostgreSQL logical replication slots.
104
+ *
105
+ * This is for restart safety only. If the same source position should become
106
+ * visible to clients, also call `commit()` or `keepalive()` at a valid source
107
+ * boundary.
83
108
  */
84
109
  setResumeLsn(lsn: string): Promise<void>;
85
110
 
@@ -90,7 +115,21 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
90
115
  */
91
116
  resumeFromLsn: string | null;
92
117
 
118
+ /**
119
+ * Mark table snapshots complete and record the source position that
120
+ * checkpoints must not precede for those tables.
121
+ */
93
122
  markTableSnapshotDone(tables: SourceTable[], no_checkpoint_before_lsn?: string): Promise<SourceTable[]>;
123
+
124
+ /**
125
+ * Mark a source table as requiring snapshot work again.
126
+ *
127
+ * Use this when a table that was previously considered snapshot-complete
128
+ * must be copied again before it can safely contribute to checkpoints. Common
129
+ * cases include detected schema or replica identity changes, interrupted
130
+ * re-snapshot work, or other source metadata changes that make the existing
131
+ * replicated rows no longer trustworthy.
132
+ */
94
133
  markTableSnapshotRequired(table: SourceTable): Promise<void>;
95
134
 
96
135
  /**
@@ -113,6 +152,10 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
113
152
  */
114
153
  markSnapshotDone(no_checkpoint_before_lsn: string, options?: { throwOnConflict?: boolean }): Promise<void>;
115
154
 
155
+ /**
156
+ * Persist resumable table snapshot progress such as row counts or source
157
+ * cursors.
158
+ */
116
159
  updateTableProgress(table: SourceTable, progress: Partial<TableSnapshotStatus>): Promise<SourceTable>;
117
160
 
118
161
  /**
@@ -120,10 +163,17 @@ export interface BucketStorageBatch extends ObserverClient<BucketBatchStorageLis
120
163
  */
121
164
  getSourceTableStatus(table: SourceTable): Promise<SourceTable | null>;
122
165
 
166
+ /**
167
+ * Resolve discovered source metadata against the active sync config and
168
+ * persisted source table state.
169
+ *
170
+ * @returns Current mappings that should receive replicated rows, plus
171
+ * outdated mappings that the source connector should drop.
172
+ */
123
173
  resolveTables(options: ResolveTablesOptions): Promise<ResolveTablesResult>;
124
174
 
125
175
  /**
126
- * Queues the creation of a custom Write Checkpoint. This will be persisted after operations are flushed.
176
+ * Queue a custom write checkpoint to be persisted after operations are flushed.
127
177
  */
128
178
  addCustomWriteCheckpoint(checkpoint: BatchedCustomWriteCheckpointOptions): void;
129
179
  }
@@ -5,7 +5,7 @@ import { BucketChecksum } from '../util/protocol-types.js';
5
5
  import { addBucketChecksums, ChecksumMap, InternalOpId, PartialChecksum } from '../util/utils.js';
6
6
  import { BucketChecksumRequest } from './SyncRulesBucketStorage.js';
7
7
 
8
- interface ChecksumFetchContext {
8
+ interface ChecksumFetchContext<TContext> {
9
9
  fetch(bucket: string): Promise<BucketChecksum>;
10
10
  checkpoint: InternalOpId;
11
11
  }
@@ -20,26 +20,35 @@ export interface FetchPartialBucketChecksum {
20
20
  export type PartialOrFullChecksum = PartialChecksum | BucketChecksum;
21
21
  export type PartialChecksumMap = Map<string, PartialOrFullChecksum>;
22
22
 
23
- export type FetchChecksums = (batch: FetchPartialBucketChecksum[]) => Promise<PartialChecksumMap>;
23
+ export type FetchChecksums<TContext = undefined> = (
24
+ batch: FetchPartialBucketChecksum[],
25
+ context: TContext
26
+ ) => Promise<PartialChecksumMap>;
24
27
 
25
- export interface ChecksumCacheOptions {
28
+ export interface ChecksumCacheOptions<TContext = undefined> {
26
29
  /**
27
30
  * Upstream checksum implementation.
28
31
  *
29
32
  * This fetches a batch of either entire bucket checksums, or a partial range.
30
33
  */
31
- fetchChecksums: FetchChecksums;
34
+ fetchChecksums: FetchChecksums<TContext>;
32
35
 
33
36
  /**
34
37
  * Maximum number of cached checksums.
35
38
  */
36
39
  maxSize?: number;
40
+
41
+ /**
42
+ * How long to keep cached checksums before refreshing them.
43
+ */
44
+ ttlMs?: number;
37
45
  }
38
46
 
39
47
  // Approximately 5MB of memory, if we assume 50 bytes per entry
40
48
  const DEFAULT_MAX_SIZE = 100_000;
41
49
 
42
- const TTL_MS = 3_600_000;
50
+ // Default to 1h
51
+ export const DEFAULT_CHECKSUM_CACHE_TTL_MS = 1 * 3_600_000;
43
52
 
44
53
  /**
45
54
  * Implement a LRU cache for checksum requests. Each (bucket, checkpoint) request is cached separately,
@@ -50,22 +59,22 @@ const TTL_MS = 3_600_000;
50
59
  *
51
60
  * We use the LRUCache fetchMethod to deduplicate in-progress requests.
52
61
  */
53
- export class ChecksumCache {
62
+ export class ChecksumCache<TContext = undefined> {
54
63
  /**
55
64
  * The primary checksum cache, with key of `${checkpoint}/${bucket}`.
56
65
  */
57
- private cache: LRUCache<string, BucketChecksum, ChecksumFetchContext>;
66
+ private cache: LRUCache<string, BucketChecksum, ChecksumFetchContext<TContext>>;
58
67
  /**
59
68
  * For each bucket, an ordered set of cached checkpoints.
60
69
  */
61
70
  private bucketCheckpoints = new Map<string, OrderedSet<bigint>>();
62
71
 
63
- private fetchChecksums: FetchChecksums;
72
+ private fetchChecksums: FetchChecksums<TContext>;
64
73
 
65
- constructor(options: ChecksumCacheOptions) {
74
+ constructor(options: ChecksumCacheOptions<TContext>) {
66
75
  this.fetchChecksums = options.fetchChecksums;
67
76
 
68
- this.cache = new LRUCache<string, BucketChecksum, ChecksumFetchContext>({
77
+ this.cache = new LRUCache<string, BucketChecksum, ChecksumFetchContext<TContext>>({
69
78
  max: options.maxSize ?? DEFAULT_MAX_SIZE,
70
79
  fetchMethod: async (cacheKey, _staleValue, options) => {
71
80
  // Called when this checksum hasn't been cached yet.
@@ -105,7 +114,7 @@ export class ChecksumCache {
105
114
  // We use a TTL so that counts can eventually be refreshed
106
115
  // after a compact. This only has effect if the bucket has
107
116
  // not been checked in the meantime.
108
- ttl: TTL_MS,
117
+ ttl: options.ttlMs ?? DEFAULT_CHECKSUM_CACHE_TTL_MS,
109
118
  ttlResolution: 1_000,
110
119
  allowStale: false
111
120
  });
@@ -129,7 +138,11 @@ export class ChecksumCache {
129
138
  *
130
139
  * @returns a Map with exactly one entry for each bucket requested
131
140
  */
132
- async getChecksumMap(checkpoint: InternalOpId, buckets: BucketChecksumRequest[]): Promise<ChecksumMap> {
141
+ async getChecksumMap(
142
+ checkpoint: InternalOpId,
143
+ buckets: BucketChecksumRequest[],
144
+ fetchContext?: TContext
145
+ ): Promise<ChecksumMap> {
133
146
  // Buckets that don't have a cached checksum for this checkpoint yet
134
147
  let toFetch = new Set<string>();
135
148
 
@@ -147,7 +160,7 @@ export class ChecksumCache {
147
160
  // Accumulated results - both from cached checksums, and fetched checksums
148
161
  let finalResults = new Map<string, BucketChecksum>();
149
162
 
150
- const context: ChecksumFetchContext = {
163
+ const context: ChecksumFetchContext<TContext> = {
151
164
  async fetch(bucket) {
152
165
  await fetchPromise;
153
166
  if (!toFetch.has(bucket)) {
@@ -260,7 +273,7 @@ export class ChecksumCache {
260
273
  }
261
274
 
262
275
  // Fetch partial checksums from upstream
263
- const results = await this.fetchChecksums(bucketRequests);
276
+ const results = await this.fetchChecksums(bucketRequests, fetchContext as TContext);
264
277
 
265
278
  for (let bucket of toFetch) {
266
279
  const result = results.get(bucket);