@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.
- package/CHANGELOG.md +22 -0
- package/dist/api/RouteAPI.d.ts +25 -11
- package/dist/replication/AbstractReplicator.d.ts +20 -1
- package/dist/replication/AbstractReplicator.js +106 -47
- 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/admin.js +2 -1
- package/dist/routes/endpoints/admin.js.map +1 -1
- package/dist/routes/endpoints/checkpointing.js +1 -3
- package/dist/routes/endpoints/checkpointing.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/PersistedSyncConfigContent.d.ts +3 -0
- package/dist/storage/PersistedSyncConfigContent.js +2 -0
- package/dist/storage/PersistedSyncConfigContent.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 +134 -47
- package/src/routes/RouterEngine.ts +7 -1
- package/src/routes/configure-fastify.ts +25 -2
- package/src/routes/endpoints/admin.ts +2 -1
- package/src/routes/endpoints/checkpointing.ts +1 -4
- package/src/storage/BucketStorageBatch.ts +61 -11
- package/src/storage/ChecksumCache.ts +27 -14
- package/src/storage/PersistedSyncConfigContent.ts +4 -0
- 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/diagnostics.test.ts +13 -12
- package/test/src/routes/admin.test.ts +12 -10
- package/test/src/sync/BucketChecksumState.test.ts +41 -1
- package/test/src/util/checkpointing.test.ts +170 -0
- 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
|
|
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
|
|
146
|
+
const loadedSyncConfig = await this.syncRuleProvider.get();
|
|
135
147
|
|
|
136
148
|
let configuredLock: storage.ReplicationLock | undefined = undefined;
|
|
137
|
-
if (
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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?: {
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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 ||
|
|
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, ...
|
|
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
|
-
|
|
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
|
-
*
|
|
41
|
+
* Queue one normalized source row operation and potentially flush.
|
|
33
42
|
*
|
|
34
|
-
*
|
|
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
|
|
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
|
|
58
|
+
* Drop one or more source table mappings.
|
|
45
59
|
*
|
|
46
|
-
* This
|
|
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
|
|
77
|
+
* Flush saved ops and advance the committed source checkpoint position.
|
|
62
78
|
*
|
|
63
|
-
* Only call this
|
|
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
|
|
87
|
+
* Advance the committed source checkpoint position without associated row
|
|
88
|
+
* operations.
|
|
69
89
|
*
|
|
70
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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);
|