@powersync/service-core 1.23.0 → 1.23.1

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.
@@ -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.
@@ -131,17 +138,20 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
131
138
  }
132
139
 
133
140
  private async runLoop() {
134
- const syncRules = await this.syncRuleProvider.get();
141
+ const loadedSyncConfig = await this.syncRuleProvider.get();
135
142
 
136
143
  let configuredLock: storage.ReplicationLock | undefined = undefined;
137
- if (syncRules != null) {
144
+ if (loadedSyncConfig != null) {
138
145
  this.logger.info('Loaded sync config');
139
146
  try {
140
147
  // Configure new sync config, if they have changed.
141
148
  // In that case, also immediately take out a lock, so that another process doesn't start replication on it.
149
+ // This upfront lock is mostly superseded by the replicationStreamLoadedSyncConfigMatch() check. However, that doesn't cover old
150
+ // versions before that check was added, so we keep the lock for now - for where te service version and sync config is updated at
151
+ // the same time.
142
152
 
143
153
  const { lock } = await this.storage.configureSyncRules(
144
- storage.updateSyncRulesFromYaml(syncRules, { lock: true, validate: this.syncRuleProvider.exitOnError })
154
+ storage.updateSyncRulesFromYaml(loadedSyncConfig, { lock: true, validate: this.syncRuleProvider.exitOnError })
145
155
  );
146
156
  if (lock) {
147
157
  configuredLock = lock;
@@ -155,10 +165,15 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
155
165
  } else {
156
166
  this.logger.info('No sync streams or rules configured - configure via API');
157
167
  }
168
+ let useFastRefresh = true;
169
+ const fastRefreshDeadline = Date.now() + FAST_REFRESH_TIMEOUT_MS;
158
170
  while (!this.stopped) {
159
171
  await container.probes.touch();
160
172
  try {
161
- await this.refresh({ configured_lock: configuredLock });
173
+ const refreshResult = await this.refresh({ configuredLock, loadedSyncConfig });
174
+ if (refreshResult.replicationJobStarted || Date.now() >= fastRefreshDeadline) {
175
+ useFastRefresh = false;
176
+ }
162
177
  // The lock is only valid on the first refresh.
163
178
  configuredLock = undefined;
164
179
 
@@ -177,24 +192,40 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
177
192
  } catch (e) {
178
193
  this.logger.error('Failed to refresh replication jobs', e);
179
194
  }
180
- await new Promise((resolve) => setTimeout(resolve, 5000));
195
+ if (Date.now() >= fastRefreshDeadline) {
196
+ useFastRefresh = false;
197
+ }
198
+ await sleep(useFastRefresh ? FAST_REFRESH_INTERVAL_MS : REFRESH_INTERVAL_MS);
181
199
  }
182
200
  }
183
201
 
184
- private async refresh(options?: { configured_lock?: storage.ReplicationLock }) {
202
+ private async refresh(options?: {
203
+ configuredLock?: storage.ReplicationLock;
204
+ loadedSyncConfig?: string;
205
+ }): Promise<{ replicationJobStarted: boolean }> {
185
206
  if (this.stopped) {
186
- return;
207
+ return { replicationJobStarted: false };
187
208
  }
188
209
 
189
- let configuredLock = options?.configured_lock;
210
+ let configuredLock = options?.configuredLock;
211
+ let replicationJobStarted = false;
190
212
 
191
213
  const existingJobs = new Map<string, T>(this.replicationJobs.entries());
192
214
  const replicatingStreams = await this.storage.getReplicatingReplicationStreams();
193
215
  const newJobs = new Map<string, T>();
216
+ const streamsToStart: storage.PersistedReplicationStream[] = [];
194
217
  let activeJob: T | undefined = undefined;
195
218
  for (let replicationStream of replicatingStreams) {
196
219
  const jobId = replicationStream.replicationJobId;
197
220
  const existingJob = existingJobs.get(jobId);
221
+ const syncConfigMismatchMessage = 'Ignoring replication stream for sync config not loaded by this process';
222
+ if (!this.shouldHandleReplicationStream(replicationStream, options?.loadedSyncConfig)) {
223
+ this.logReplicationStreamInfoOnce(replicationStream, 'sync-config-mismatch', () => {
224
+ replicationStream.logger.info(syncConfigMismatchMessage);
225
+ });
226
+ continue;
227
+ }
228
+ this.clearReplicationStreamInfoLog(replicationStream, 'sync-config-mismatch');
198
229
  if (replicationStream.state == storage.SyncRuleState.ACTIVE && activeJob == null) {
199
230
  activeJob = existingJob;
200
231
  }
@@ -208,47 +239,12 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
208
239
  existingJobs.delete(jobId);
209
240
  } else {
210
241
  // 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
- }
242
+ streamsToStart.push(replicationStream);
244
243
  }
245
244
  }
246
245
 
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
246
+ // Stop any orphaned jobs that no longer have a replication stream before starting replacements.
247
+ // Termination happens below.
252
248
  for (let job of existingJobs.values()) {
253
249
  // Old - stop and clean up
254
250
  try {
@@ -259,6 +255,46 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
259
255
  }
260
256
  }
261
257
 
258
+ for (let replicationStream of streamsToStart) {
259
+ const jobId = replicationStream.replicationJobId;
260
+ try {
261
+ let lock: storage.ReplicationLock;
262
+ if (configuredLock?.sync_rules_id == replicationStream.replicationStreamId) {
263
+ lock = configuredLock;
264
+ } else {
265
+ lock = await replicationStream.lock();
266
+ }
267
+ const syncRuleStorage = this.storage.getInstance(replicationStream);
268
+ const newJob = this.createJob({
269
+ lock: lock,
270
+ storage: syncRuleStorage
271
+ });
272
+
273
+ newJobs.set(jobId, newJob);
274
+ newJob.start();
275
+ replicationJobStarted = true;
276
+ if (replicationStream.state == storage.SyncRuleState.ACTIVE) {
277
+ activeJob = newJob;
278
+ }
279
+ this.lastReplicationStreamInfoLogs.delete(replicationStream.replicationStreamName);
280
+ } catch (e) {
281
+ if (e?.errorData?.code === ErrorCode.PSYNC_S1003) {
282
+ this.logReplicationStreamInfoOnce(replicationStream, 'replication-stream-locked', () => {
283
+ replicationStream.logger.info(`[${e.errorData.code}] ${e.errorData.description}`);
284
+ });
285
+ } else {
286
+ // Could be a sync config parse error,
287
+ // for example from stricter validation that was added.
288
+ // This will be retried every couple of seconds.
289
+ // When new (valid) sync config is deployed and processed, this one be disabled.
290
+ replicationStream.logger.error('Failed to start replication for new sync config', e);
291
+ }
292
+ }
293
+ }
294
+
295
+ this.replicationJobs = newJobs;
296
+ this.activeReplicationJob = activeJob;
297
+
262
298
  // Replication stream stopped previously, including by a different process.
263
299
  const stopped = await this.storage.getStoppedReplicationStreams();
264
300
  for (let replicationStream of stopped) {
@@ -280,6 +316,49 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
280
316
  });
281
317
  this.clearingJobs.set(replicationStream.replicationStreamId, promise);
282
318
  }
319
+
320
+ return { replicationJobStarted };
321
+ }
322
+
323
+ private logReplicationStreamInfoOnce(
324
+ replicationStream: storage.PersistedReplicationStream,
325
+ category: string,
326
+ log: () => void
327
+ ) {
328
+ if (this.lastReplicationStreamInfoLogs.get(replicationStream.replicationStreamName) == category) {
329
+ return;
330
+ }
331
+
332
+ log();
333
+ this.lastReplicationStreamInfoLogs.set(replicationStream.replicationStreamName, category);
334
+ }
335
+
336
+ private clearReplicationStreamInfoLog(replicationStream: storage.PersistedReplicationStream, category: string) {
337
+ if (this.lastReplicationStreamInfoLogs.get(replicationStream.replicationStreamName) == category) {
338
+ this.lastReplicationStreamInfoLogs.delete(replicationStream.replicationStreamName);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * When we load sync config from the filesystem/config source, we ignore any config loaded by other processes.
344
+ * The idea is that if a different process loads updated config, that process should process it, not this one.
345
+ *
346
+ * This specifically helps for cases with rolling deploys.
347
+ *
348
+ * This does not apply when sync config is loaded from the database/API, instead of in the config.
349
+ */
350
+ private shouldHandleReplicationStream(
351
+ replicationStream: storage.PersistedReplicationStream,
352
+ loadedSyncRules: string | undefined
353
+ ) {
354
+ if (loadedSyncRules == null) {
355
+ return true;
356
+ }
357
+
358
+ const processingConfig = replicationStream.syncConfigContent.find(
359
+ (syncConfig) => syncConfig.syncConfigState == storage.SyncRuleState.PROCESSING
360
+ );
361
+ return processingConfig == null || processingConfig.sync_rules_content == loadedSyncRules;
283
362
  }
284
363
 
285
364
  protected createJobId(syncRuleId: number) {
@@ -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();
@@ -17,6 +17,7 @@ import {
17
17
  } from '@powersync/service-sync-rules';
18
18
  import * as sqlite from 'node:sqlite';
19
19
  import { Logger } from 'winston';
20
+ import { SyncRuleState } from './BucketStorage.js';
20
21
  import { SerializedSyncPlan, UpdateSyncRulesOptions } from './BucketStorageFactory.js';
21
22
  import { ParsedSyncConfigSet } from './ParsedSyncConfigSet.js';
22
23
  import { PersistedSyncConfigStatus } from './PersistedSyncConfigStatus.js';
@@ -91,6 +92,7 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
91
92
  readonly storageVersion: number;
92
93
  readonly logger: Logger;
93
94
  readonly syncConfigId: PersistedSyncConfigId | null;
95
+ readonly syncConfigState: SyncRuleState;
94
96
 
95
97
  constructor(data: PersistedSyncConfigContentData) {
96
98
  this.replicationStreamId = data.replicationStreamId;
@@ -99,6 +101,7 @@ export abstract class PersistedSyncConfigContent implements PersistedSyncConfigC
99
101
  this.replicationStreamName = data.replicationStreamName;
100
102
  this.storageVersion = data.storageVersion;
101
103
  this.syncConfigId = data.syncConfigId ?? null;
104
+ this.syncConfigState = data.syncConfigState;
102
105
  this.logger = defaultLogger.child({ prefix: `[${this.replicationStreamName}] ` });
103
106
  }
104
107
 
@@ -181,6 +184,7 @@ export interface PersistedSyncConfigContentData {
181
184
  readonly storageVersion: number;
182
185
 
183
186
  readonly syncConfigId?: PersistedSyncConfigId | null;
187
+ readonly syncConfigState: SyncRuleState;
184
188
  }
185
189
  export type PersistedSyncConfigId = string;
186
190
  export interface ParseSyncConfigOptions {
@@ -18,6 +18,17 @@ function makeSyncRulesContent(overrides?: {
18
18
  status?: storage.PersistedSyncConfigStatus;
19
19
  }): storage.PersistedSyncConfigContent {
20
20
  // We don't implement the entire interface correctly here - just enough to test the diagnostics logic.
21
+ const status = overrides?.status ?? {
22
+ id: '1',
23
+ replicationStreamId: 1,
24
+ state: storage.SyncRuleState.ACTIVE,
25
+ last_checkpoint_lsn: 'some_lsn',
26
+ last_fatal_error: null,
27
+ last_fatal_error_ts: null,
28
+ last_keepalive_ts: new Date(),
29
+ last_checkpoint_ts: new Date()
30
+ };
31
+
21
32
  return {
22
33
  replicationStreamId: 1,
23
34
  syncConfigId: null,
@@ -25,6 +36,7 @@ function makeSyncRulesContent(overrides?: {
25
36
  sync_rules_content: MINIMAL_SYNC_RULES,
26
37
  compiled_plan: null,
27
38
  storageVersion: 1,
39
+ syncConfigState: status.state,
28
40
  parsed(options?: any) {
29
41
  const syncRules = SqlSyncRules.fromYaml(MINIMAL_SYNC_RULES, {
30
42
  ...options,
@@ -38,18 +50,7 @@ function makeSyncRulesContent(overrides?: {
38
50
  asUpdateOptions: null as any,
39
51
  getStorageConfig: null as any,
40
52
  async getSyncConfigStatus() {
41
- return (
42
- overrides?.status ?? {
43
- id: '1',
44
- replicationStreamId: 1,
45
- state: storage.SyncRuleState.ACTIVE,
46
- last_checkpoint_lsn: 'some_lsn',
47
- last_fatal_error: null,
48
- last_fatal_error_ts: null,
49
- last_keepalive_ts: new Date(),
50
- last_checkpoint_ts: new Date()
51
- }
52
- );
53
+ return status;
53
54
  }
54
55
  } as storage.PersistedSyncConfigContent;
55
56
  }
@@ -41,6 +41,16 @@ describe('admin routes', () => {
41
41
  const state = active ? storage.SyncRuleState.ACTIVE : storage.SyncRuleState.PROCESSING;
42
42
  const lastKeepaliveTs = new Date('2026-01-01T00:00:00.000Z');
43
43
  const lastCheckpointTs = new Date('2026-01-01T00:00:00.000Z');
44
+ const syncConfigStatus = {
45
+ id: syncConfigId,
46
+ replicationStreamId: id,
47
+ state,
48
+ last_checkpoint_lsn: null,
49
+ last_fatal_error: null,
50
+ last_fatal_error_ts: null,
51
+ last_keepalive_ts: lastKeepaliveTs,
52
+ last_checkpoint_ts: lastCheckpointTs
53
+ };
44
54
  const content = {
45
55
  replicationStreamId: id,
46
56
  syncConfigId,
@@ -55,6 +65,7 @@ bucket_definitions:
55
65
  `,
56
66
  compiled_plan: null,
57
67
  storageVersion: storage.LEGACY_STORAGE_VERSION,
68
+ syncConfigState: syncConfigStatus.state,
58
69
  parsed(options?: any) {
59
70
  const syncRules = SqlSyncRules.fromYaml(content.sync_rules_content, {
60
71
  ...options,
@@ -67,16 +78,7 @@ bucket_definitions:
67
78
  asUpdateOptions: vi.fn(),
68
79
  getStorageConfig: vi.fn(),
69
80
  async getSyncConfigStatus() {
70
- return {
71
- id: syncConfigId,
72
- replicationStreamId: id,
73
- state,
74
- last_checkpoint_lsn: null,
75
- last_fatal_error: null,
76
- last_fatal_error_ts: null,
77
- last_keepalive_ts: lastKeepaliveTs,
78
- last_checkpoint_ts: lastCheckpointTs
79
- };
81
+ return syncConfigStatus;
80
82
  }
81
83
  };
82
84
  return content as unknown as storage.PersistedSyncConfigContent;