@powersync/service-module-mssql 0.0.0-dev-20260225160713 → 0.0.0-dev-20260511080634
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 +113 -6
- package/dist/api/MSSQLRouteAPIAdapter.d.ts +2 -2
- package/dist/api/MSSQLRouteAPIAdapter.js +3 -3
- package/dist/api/MSSQLRouteAPIAdapter.js.map +1 -1
- package/dist/common/CaptureInstance.d.ts +14 -0
- package/dist/common/CaptureInstance.js +2 -0
- package/dist/common/CaptureInstance.js.map +1 -0
- package/dist/common/MSSQLSourceTable.d.ts +16 -14
- package/dist/common/MSSQLSourceTable.js +35 -16
- package/dist/common/MSSQLSourceTable.js.map +1 -1
- package/dist/common/MSSQLSourceTableCache.js.map +1 -1
- package/dist/common/mssqls-to-sqlite.d.ts +1 -1
- package/dist/common/mssqls-to-sqlite.js +1 -1
- package/dist/common/mssqls-to-sqlite.js.map +1 -1
- package/dist/module/MSSQLModule.js +4 -4
- package/dist/module/MSSQLModule.js.map +1 -1
- package/dist/replication/CDCPoller.d.ts +45 -23
- package/dist/replication/CDCPoller.js +201 -61
- package/dist/replication/CDCPoller.js.map +1 -1
- package/dist/replication/CDCReplicationJob.d.ts +2 -2
- package/dist/replication/CDCReplicationJob.js +12 -4
- package/dist/replication/CDCReplicationJob.js.map +1 -1
- package/dist/replication/CDCReplicator.d.ts +2 -3
- package/dist/replication/CDCReplicator.js +1 -24
- package/dist/replication/CDCReplicator.js.map +1 -1
- package/dist/replication/CDCStream.d.ts +39 -16
- package/dist/replication/CDCStream.js +200 -103
- package/dist/replication/CDCStream.js.map +1 -1
- package/dist/replication/MSSQLConnectionManager.d.ts +1 -1
- package/dist/replication/MSSQLConnectionManager.js +17 -6
- package/dist/replication/MSSQLConnectionManager.js.map +1 -1
- package/dist/replication/MSSQLConnectionManagerFactory.d.ts +1 -1
- package/dist/replication/MSSQLConnectionManagerFactory.js.map +1 -1
- package/dist/replication/MSSQLSnapshotQuery.d.ts +1 -1
- package/dist/replication/MSSQLSnapshotQuery.js +2 -2
- package/dist/replication/MSSQLSnapshotQuery.js.map +1 -1
- package/dist/types/types.d.ts +4 -56
- package/dist/types/types.js +5 -24
- package/dist/types/types.js.map +1 -1
- package/dist/utils/deadlock.d.ts +9 -0
- package/dist/utils/deadlock.js +40 -0
- package/dist/utils/deadlock.js.map +1 -0
- package/dist/utils/mssql.d.ts +36 -18
- package/dist/utils/mssql.js +102 -100
- package/dist/utils/mssql.js.map +1 -1
- package/dist/utils/schema.d.ts +9 -0
- package/dist/utils/schema.js +34 -0
- package/dist/utils/schema.js.map +1 -1
- package/package.json +14 -14
- package/src/api/MSSQLRouteAPIAdapter.ts +4 -4
- package/src/common/CaptureInstance.ts +15 -0
- package/src/common/MSSQLSourceTable.ts +33 -24
- package/src/common/MSSQLSourceTableCache.ts +1 -1
- package/src/common/mssqls-to-sqlite.ts +1 -1
- package/src/module/MSSQLModule.ts +4 -4
- package/src/replication/CDCPoller.ts +275 -75
- package/src/replication/CDCReplicationJob.ts +13 -6
- package/src/replication/CDCReplicator.ts +2 -28
- package/src/replication/CDCStream.ts +271 -128
- package/src/replication/MSSQLConnectionManager.ts +17 -7
- package/src/replication/MSSQLConnectionManagerFactory.ts +1 -1
- package/src/replication/MSSQLSnapshotQuery.ts +3 -3
- package/src/types/types.ts +5 -28
- package/src/utils/deadlock.ts +44 -0
- package/src/utils/mssql.ts +163 -128
- package/src/utils/schema.ts +44 -1
- package/test/src/CDCStream.test.ts +10 -8
- package/test/src/CDCStreamTestContext.ts +32 -12
- package/test/src/CDCStream_resumable_snapshot.test.ts +14 -12
- package/test/src/LSN.test.ts +1 -1
- package/test/src/env.ts +1 -1
- package/test/src/mssql-to-sqlite.test.ts +25 -17
- package/test/src/schema-changes.test.ts +474 -0
- package/test/src/util.ts +81 -18
- package/test/tsconfig.json +0 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,45 +1,43 @@
|
|
|
1
1
|
import {
|
|
2
2
|
container,
|
|
3
3
|
DatabaseConnectionError,
|
|
4
|
+
logger as defaultLogger,
|
|
4
5
|
ErrorCode,
|
|
5
6
|
Logger,
|
|
6
|
-
logger as defaultLogger,
|
|
7
7
|
ReplicationAbortedError,
|
|
8
|
-
ReplicationAssertionError
|
|
9
|
-
ServiceAssertionError
|
|
8
|
+
ReplicationAssertionError
|
|
10
9
|
} from '@powersync/lib-services-framework';
|
|
11
|
-
import { getUuidReplicaIdentityBson, MetricsEngine, SourceEntityDescriptor, storage } from '@powersync/service-core';
|
|
12
|
-
|
|
13
10
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from '@powersync/service-
|
|
11
|
+
getUuidReplicaIdentityBson,
|
|
12
|
+
MetricsEngine,
|
|
13
|
+
ReplicationLagTracker,
|
|
14
|
+
SourceEntityDescriptor,
|
|
15
|
+
storage
|
|
16
|
+
} from '@powersync/service-core';
|
|
17
|
+
|
|
18
|
+
import { HydratedSyncRules, SqliteInputRow, SqliteRow, TablePattern } from '@powersync/service-sync-rules';
|
|
20
19
|
|
|
21
20
|
import { ReplicationMetric } from '@powersync/service-types';
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
21
|
+
import sql from 'mssql';
|
|
22
|
+
import { CaptureInstance } from '../common/CaptureInstance.js';
|
|
23
|
+
import { LSN } from '../common/LSN.js';
|
|
24
|
+
import { CDCToSqliteRow, toSqliteInputRow } from '../common/mssqls-to-sqlite.js';
|
|
25
|
+
import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
|
|
26
|
+
import { MSSQLSourceTableCache } from '../common/MSSQLSourceTableCache.js';
|
|
27
|
+
import { AdditionalConfig } from '../types/types.js';
|
|
25
28
|
import {
|
|
29
|
+
checkRetentionThresholds,
|
|
26
30
|
checkSourceConfiguration,
|
|
27
31
|
createCheckpoint,
|
|
28
|
-
|
|
32
|
+
getCaptureInstances,
|
|
29
33
|
getLatestLSN,
|
|
30
34
|
getLatestReplicatedLSN,
|
|
31
|
-
isIColumnMetadata
|
|
32
|
-
isTableEnabledForCDC,
|
|
33
|
-
isWithinRetentionThreshold,
|
|
34
|
-
toQualifiedTableName
|
|
35
|
+
isIColumnMetadata
|
|
35
36
|
} from '../utils/mssql.js';
|
|
36
|
-
import
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import { MSSQLSourceTableCache } from '../common/MSSQLSourceTableCache.js';
|
|
41
|
-
import { CDCEventHandler, CDCPoller } from './CDCPoller.js';
|
|
42
|
-
import { AdditionalConfig } from '../types/types.js';
|
|
37
|
+
import { getReplicationIdentityColumns, getTablesFromPattern, ResolvedTable } from '../utils/schema.js';
|
|
38
|
+
import { CDCEventHandler, CDCPoller, SchemaChange, SchemaChangeType } from './CDCPoller.js';
|
|
39
|
+
import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
|
|
40
|
+
import { BatchedSnapshotQuery, MSSQLSnapshotQuery, SimpleSnapshotQuery } from './MSSQLSnapshotQuery.js';
|
|
43
41
|
|
|
44
42
|
export interface CDCStreamOptions {
|
|
45
43
|
connections: MSSQLConnectionManager;
|
|
@@ -55,17 +53,30 @@ export interface CDCStreamOptions {
|
|
|
55
53
|
snapshotBatchSize?: number;
|
|
56
54
|
|
|
57
55
|
additionalConfig: AdditionalConfig;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Override schema check interval, defaults is 60 seconds.
|
|
59
|
+
*/
|
|
60
|
+
schemaCheckIntervalMs?: number;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
export enum SnapshotStatus {
|
|
61
|
-
|
|
64
|
+
INITIAL = 'initial',
|
|
65
|
+
RESUME = 'resume',
|
|
62
66
|
DONE = 'done',
|
|
63
|
-
|
|
67
|
+
LIMITED_RESNAPSHOT = 'limited-resnapshot'
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
export interface SnapshotStatusResult {
|
|
67
71
|
status: SnapshotStatus;
|
|
68
72
|
snapshotLSN: string | null;
|
|
73
|
+
/**
|
|
74
|
+
* Under certain circumstances it may be necessary to re-snapshot specific tables:
|
|
75
|
+
* * A new capture instance has been created for a table.
|
|
76
|
+
* * A table has been renamed.
|
|
77
|
+
* * The retention threshold has been exceeded for a table.
|
|
78
|
+
*/
|
|
79
|
+
specificTablesToResnapshot?: MSSQLSourceTable[];
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
export class CDCConfigurationError extends Error {
|
|
@@ -94,18 +105,9 @@ export class CDCStream {
|
|
|
94
105
|
private readonly abortSignal: AbortSignal;
|
|
95
106
|
private readonly logger: Logger;
|
|
96
107
|
|
|
97
|
-
|
|
108
|
+
public tableCache = new MSSQLSourceTableCache();
|
|
98
109
|
|
|
99
|
-
|
|
100
|
-
* Time of the oldest uncommitted change, according to the source db.
|
|
101
|
-
* This is used to determine the replication lag.
|
|
102
|
-
*/
|
|
103
|
-
private oldestUncommittedChange: Date | null = null;
|
|
104
|
-
/**
|
|
105
|
-
* Keep track of whether we have done a commit or keepalive yet.
|
|
106
|
-
* We can only compute replication lag if isStartingReplication == false, or oldestUncommittedChange is present.
|
|
107
|
-
*/
|
|
108
|
-
public isStartingReplication = true;
|
|
110
|
+
private replicationLag = new ReplicationLagTracker();
|
|
109
111
|
|
|
110
112
|
constructor(private options: CDCStreamOptions) {
|
|
111
113
|
this.logger = options.logger ?? defaultLogger;
|
|
@@ -123,6 +125,10 @@ export class CDCStream {
|
|
|
123
125
|
return this.abortSignal.aborted;
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
get isStartingReplication() {
|
|
129
|
+
return this.replicationLag.isStartingReplication;
|
|
130
|
+
}
|
|
131
|
+
|
|
126
132
|
get defaultSchema() {
|
|
127
133
|
return this.connections.schema;
|
|
128
134
|
}
|
|
@@ -172,14 +178,11 @@ export class CDCStream {
|
|
|
172
178
|
logger: this.logger,
|
|
173
179
|
zeroLSN: LSN.ZERO,
|
|
174
180
|
defaultSchema: this.defaultSchema,
|
|
175
|
-
storeCurrentData:
|
|
181
|
+
storeCurrentData: false
|
|
176
182
|
},
|
|
177
183
|
async (batch) => {
|
|
178
184
|
for (let tablePattern of sourceTables) {
|
|
179
|
-
|
|
180
|
-
for (const table of tables) {
|
|
181
|
-
this.tableCache.set(table);
|
|
182
|
-
}
|
|
185
|
+
await this.getQualifiedTableNames(batch, tablePattern);
|
|
183
186
|
}
|
|
184
187
|
}
|
|
185
188
|
);
|
|
@@ -193,21 +196,12 @@ export class CDCStream {
|
|
|
193
196
|
return [];
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
const captureInstances = await getCaptureInstances({ connectionManager: this.connections });
|
|
196
200
|
const matchedTables: ResolvedTable[] = await getTablesFromPattern(this.connections, tablePattern);
|
|
197
201
|
|
|
198
202
|
const tables: MSSQLSourceTable[] = [];
|
|
199
203
|
for (const matchedTable of matchedTables) {
|
|
200
|
-
const
|
|
201
|
-
connectionManager: this.connections,
|
|
202
|
-
table: matchedTable.name,
|
|
203
|
-
schema: matchedTable.schema
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
if (!isEnabled) {
|
|
207
|
-
this.logger.info(`Skipping ${matchedTable.schema}.${matchedTable.name} - table is not enabled for CDC.`);
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
|
|
204
|
+
const captureInstanceDetails = captureInstances.get(matchedTable.objectId as number);
|
|
211
205
|
// TODO: Check RLS settings for table
|
|
212
206
|
|
|
213
207
|
const replicaIdColumns = await getReplicationIdentityColumns({
|
|
@@ -224,6 +218,7 @@ export class CDCStream {
|
|
|
224
218
|
objectId: matchedTable.objectId,
|
|
225
219
|
replicaIdColumns: replicaIdColumns.columns
|
|
226
220
|
},
|
|
221
|
+
captureInstanceDetails?.instances[0] ?? null,
|
|
227
222
|
false
|
|
228
223
|
);
|
|
229
224
|
|
|
@@ -235,6 +230,7 @@ export class CDCStream {
|
|
|
235
230
|
async processTable(
|
|
236
231
|
batch: storage.BucketStorageBatch,
|
|
237
232
|
table: SourceEntityDescriptor,
|
|
233
|
+
captureInstance: CaptureInstance | null,
|
|
238
234
|
snapshot: boolean
|
|
239
235
|
): Promise<MSSQLSourceTable> {
|
|
240
236
|
if (!table.objectId && typeof table.objectId != 'number') {
|
|
@@ -247,40 +243,33 @@ export class CDCStream {
|
|
|
247
243
|
entity_descriptor: table,
|
|
248
244
|
sync_rules: this.syncRules
|
|
249
245
|
});
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
tableName: resolved.table.name,
|
|
253
|
-
schema: resolved.table.schema
|
|
254
|
-
});
|
|
246
|
+
const resolvedTable = new MSSQLSourceTable(resolved.table);
|
|
247
|
+
|
|
255
248
|
if (!captureInstance) {
|
|
256
|
-
|
|
257
|
-
`Missing capture instance for table ${
|
|
249
|
+
this.logger.warn(
|
|
250
|
+
`Missing capture instance for table ${resolvedTable.toQualifiedName()}. This table will not be replicated until CDC is enabled for it.`
|
|
258
251
|
);
|
|
252
|
+
} else {
|
|
253
|
+
resolvedTable.setCaptureInstance(captureInstance);
|
|
259
254
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
captureInstance: captureInstance
|
|
263
|
-
});
|
|
255
|
+
|
|
256
|
+
this.tableCache.set(resolvedTable);
|
|
264
257
|
|
|
265
258
|
// Drop conflicting tables. This includes for example renamed tables.
|
|
266
259
|
await batch.drop(resolved.dropTables);
|
|
267
260
|
|
|
268
261
|
// Snapshot if:
|
|
269
|
-
// 1.
|
|
270
|
-
// 2.
|
|
271
|
-
|
|
272
|
-
|
|
262
|
+
// 1. The table is in the sync config and snapshot is requested, or not already done.
|
|
263
|
+
// 2. AND the table is enabled for CDC with a valid capture instance.
|
|
264
|
+
const shouldSnapshot =
|
|
265
|
+
snapshot && !resolved.table.snapshotComplete && resolved.table.syncAny && resolvedTable.enabledForCDC();
|
|
273
266
|
|
|
274
267
|
if (shouldSnapshot) {
|
|
275
268
|
// Truncate this table in case a previous snapshot was interrupted.
|
|
276
269
|
await batch.truncate([resolved.table]);
|
|
277
270
|
|
|
278
271
|
// Start the snapshot inside a transaction.
|
|
279
|
-
|
|
280
|
-
await this.snapshotTableInTx(batch, resolvedTable);
|
|
281
|
-
} finally {
|
|
282
|
-
// TODO Cleanup?
|
|
283
|
-
}
|
|
272
|
+
await this.snapshotTableInTx(batch, resolvedTable);
|
|
284
273
|
}
|
|
285
274
|
|
|
286
275
|
return resolvedTable;
|
|
@@ -310,7 +299,10 @@ export class CDCStream {
|
|
|
310
299
|
const postSnapshotLSN = await getLatestLSN(this.connections);
|
|
311
300
|
// Side note: A ROLLBACK would probably also be fine here, since we only read in this transaction.
|
|
312
301
|
await transaction.commit();
|
|
313
|
-
const [updatedSourceTable] = await batch.
|
|
302
|
+
const [updatedSourceTable] = await batch.markTableSnapshotDone([table.sourceTable], postSnapshotLSN.toString());
|
|
303
|
+
this.logger.info(
|
|
304
|
+
`Snapshot of ${table.toQualifiedName()} completed. Post-snapshot LSN: ${postSnapshotLSN.toString()}`
|
|
305
|
+
);
|
|
314
306
|
this.tableCache.updateSourceTable(updatedSourceTable);
|
|
315
307
|
} catch (e) {
|
|
316
308
|
await transaction.rollback();
|
|
@@ -341,17 +333,17 @@ export class CDCStream {
|
|
|
341
333
|
);
|
|
342
334
|
if (table.sourceTable.snapshotStatus?.lastKey != null) {
|
|
343
335
|
this.logger.info(
|
|
344
|
-
`
|
|
336
|
+
`Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resuming from ${orderByKey.name} > ${(query as BatchedSnapshotQuery).lastKey}`
|
|
345
337
|
);
|
|
346
338
|
} else {
|
|
347
339
|
this.logger.info(
|
|
348
|
-
`
|
|
340
|
+
`Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - resumable`
|
|
349
341
|
);
|
|
350
342
|
}
|
|
351
343
|
} else {
|
|
352
344
|
// Fallback case - query the entire table
|
|
353
345
|
this.logger.info(
|
|
354
|
-
`
|
|
346
|
+
`Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()} - not resumable`
|
|
355
347
|
);
|
|
356
348
|
query = new SimpleSnapshotQuery(transaction, table);
|
|
357
349
|
replicatedCount = 0;
|
|
@@ -417,8 +409,6 @@ export class CDCStream {
|
|
|
417
409
|
});
|
|
418
410
|
this.tableCache.updateSourceTable(updatedSourceTable);
|
|
419
411
|
|
|
420
|
-
this.logger.info(`Replicating ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
|
|
421
|
-
|
|
422
412
|
if (this.abortSignal.aborted) {
|
|
423
413
|
// We only abort after flushing
|
|
424
414
|
throw new ReplicationAbortedError(`Initial replication interrupted`);
|
|
@@ -427,6 +417,8 @@ export class CDCStream {
|
|
|
427
417
|
// When the batch of rows is smaller than the requested batch size we know it is the final batch
|
|
428
418
|
if (batchReplicatedCount < this.snapshotBatchSize) {
|
|
429
419
|
hasRemainingData = false;
|
|
420
|
+
} else {
|
|
421
|
+
this.logger.info(`Snapshotting ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
|
|
430
422
|
}
|
|
431
423
|
}
|
|
432
424
|
}
|
|
@@ -455,13 +447,7 @@ export class CDCStream {
|
|
|
455
447
|
* and starts again from scratch.
|
|
456
448
|
*/
|
|
457
449
|
async startInitialReplication(snapshotStatus: SnapshotStatusResult) {
|
|
458
|
-
let { status, snapshotLSN } = snapshotStatus;
|
|
459
|
-
|
|
460
|
-
if (status === SnapshotStatus.RESTART_REQUIRED) {
|
|
461
|
-
this.logger.info(`Snapshot restart required, clearing state.`);
|
|
462
|
-
// This happens if the last replicated checkpoint LSN is no longer available in the CDC tables.
|
|
463
|
-
await this.storage.clear({ signal: this.abortSignal });
|
|
464
|
-
}
|
|
450
|
+
let { status, snapshotLSN, specificTablesToResnapshot } = snapshotStatus;
|
|
465
451
|
|
|
466
452
|
await this.storage.startBatch(
|
|
467
453
|
{
|
|
@@ -472,14 +458,26 @@ export class CDCStream {
|
|
|
472
458
|
skipExistingRows: true
|
|
473
459
|
},
|
|
474
460
|
async (batch) => {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
461
|
+
switch (status) {
|
|
462
|
+
case SnapshotStatus.INITIAL:
|
|
463
|
+
// First replication attempt - set the snapshot LSN to the current LSN before starting
|
|
464
|
+
snapshotLSN = (await getLatestReplicatedLSN(this.connections)).toString();
|
|
465
|
+
await batch.setResumeLsn(snapshotLSN);
|
|
466
|
+
const latestLSN = (await getLatestLSN(this.connections)).toString();
|
|
467
|
+
this.logger.info(`Marking snapshot at ${snapshotLSN}, Latest DB LSN ${latestLSN}.`);
|
|
468
|
+
break;
|
|
469
|
+
case SnapshotStatus.RESUME:
|
|
470
|
+
this.logger.info(`Resuming snapshot at ${snapshotLSN}.`);
|
|
471
|
+
break;
|
|
472
|
+
case SnapshotStatus.LIMITED_RESNAPSHOT:
|
|
473
|
+
for (const table of specificTablesToResnapshot!) {
|
|
474
|
+
await batch.drop([table.sourceTable]);
|
|
475
|
+
// Update table in the table cache
|
|
476
|
+
await this.processTable(batch, table.sourceTable, table.captureInstance, false);
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
default:
|
|
480
|
+
throw new ReplicationAssertionError(`Unsupported snapshot status: ${status}`);
|
|
483
481
|
}
|
|
484
482
|
|
|
485
483
|
const tablesToSnapshot: MSSQLSourceTable[] = [];
|
|
@@ -489,14 +487,17 @@ export class CDCStream {
|
|
|
489
487
|
continue;
|
|
490
488
|
}
|
|
491
489
|
|
|
490
|
+
if (!table.enabledForCDC()) {
|
|
491
|
+
this.logger.info(`Skipping table [${table.toQualifiedName()}] - not enabled for CDC.`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
492
495
|
const count = await this.estimatedCountNumber(table);
|
|
493
496
|
const updatedSourceTable = await batch.updateTableProgress(table.sourceTable, {
|
|
494
497
|
totalEstimatedCount: count
|
|
495
498
|
});
|
|
496
499
|
this.tableCache.updateSourceTable(updatedSourceTable);
|
|
497
500
|
tablesToSnapshot.push(table);
|
|
498
|
-
|
|
499
|
-
this.logger.info(`To replicate: ${table.toQualifiedName()} ${table.sourceTable.formatSnapshotProgress()}`);
|
|
500
501
|
}
|
|
501
502
|
|
|
502
503
|
for (const table of tablesToSnapshot) {
|
|
@@ -506,11 +507,17 @@ export class CDCStream {
|
|
|
506
507
|
|
|
507
508
|
// This will not create a consistent checkpoint yet, but will persist the op.
|
|
508
509
|
// Actual checkpoint will be created when streaming replication caught up.
|
|
509
|
-
await
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
)
|
|
510
|
+
const postSnapshotLSN = await getLatestLSN(this.connections);
|
|
511
|
+
await batch.markAllSnapshotDone(postSnapshotLSN.toString());
|
|
512
|
+
await batch.commit(snapshotLSN!);
|
|
513
|
+
|
|
514
|
+
if (tablesToSnapshot.length > 0) {
|
|
515
|
+
this.logger.info(
|
|
516
|
+
`All snapshots done. Need to replicate from ${snapshotLSN} to ${postSnapshotLSN} to be consistent.`
|
|
517
|
+
);
|
|
518
|
+
} else {
|
|
519
|
+
this.logger.info(`No tables to snapshot. Need to replicate from ${snapshotLSN}.`);
|
|
520
|
+
}
|
|
514
521
|
}
|
|
515
522
|
);
|
|
516
523
|
}
|
|
@@ -525,6 +532,8 @@ export class CDCStream {
|
|
|
525
532
|
const snapshotStatus = await this.checkSnapshotStatus();
|
|
526
533
|
if (snapshotStatus.status !== SnapshotStatus.DONE) {
|
|
527
534
|
await this.startInitialReplication(snapshotStatus);
|
|
535
|
+
} else {
|
|
536
|
+
this.logger.info(`Initial replication already done`);
|
|
528
537
|
}
|
|
529
538
|
}
|
|
530
539
|
|
|
@@ -534,26 +543,50 @@ export class CDCStream {
|
|
|
534
543
|
*/
|
|
535
544
|
private async checkSnapshotStatus(): Promise<SnapshotStatusResult> {
|
|
536
545
|
const status = await this.storage.getStatus();
|
|
546
|
+
|
|
537
547
|
if (status.snapshot_done && status.checkpoint_lsn) {
|
|
548
|
+
const additionalTablesToSnapshot: Set<MSSQLSourceTable> = new Set();
|
|
549
|
+
const newTables = this.tableCache.getAll().filter((table) => !table.sourceTable.snapshotComplete);
|
|
550
|
+
if (newTables.length > 0) {
|
|
551
|
+
this.logger.info(
|
|
552
|
+
`Detected new table(s) [${newTables.map((table) => table.toQualifiedName()).join(', ')}] that have not been snapshotted yet.`
|
|
553
|
+
);
|
|
554
|
+
newTables.forEach((table) => additionalTablesToSnapshot.add(table));
|
|
555
|
+
}
|
|
556
|
+
|
|
538
557
|
// Snapshot is done, but we still need to check that the last known checkpoint LSN is still
|
|
539
|
-
// within the threshold of the CDC tables
|
|
540
|
-
this.logger.info(`Initial replication already done`);
|
|
558
|
+
// within the retention threshold of the CDC tables
|
|
541
559
|
|
|
542
560
|
const lastCheckpointLSN = LSN.fromString(status.checkpoint_lsn);
|
|
543
561
|
// Check that the CDC tables still have valid data
|
|
544
|
-
const
|
|
562
|
+
const tablesOutsideRetentionThreshold = await checkRetentionThresholds({
|
|
545
563
|
checkpointLSN: lastCheckpointLSN,
|
|
546
564
|
tables: this.tableCache.getAll(),
|
|
547
565
|
connectionManager: this.connections
|
|
548
566
|
});
|
|
549
|
-
if (
|
|
567
|
+
if (tablesOutsideRetentionThreshold.length > 0) {
|
|
550
568
|
this.logger.warn(
|
|
551
|
-
`Updates from the last checkpoint are no longer available in the CDC
|
|
569
|
+
`Updates from the last checkpoint are no longer available in the CDC instances of the following table(s): ${tablesOutsideRetentionThreshold.map((table) => table.toQualifiedName()).join(', ')}.`
|
|
552
570
|
);
|
|
571
|
+
tablesOutsideRetentionThreshold.forEach((table) => additionalTablesToSnapshot.add(table));
|
|
572
|
+
}
|
|
573
|
+
if (additionalTablesToSnapshot.size > 0) {
|
|
574
|
+
return {
|
|
575
|
+
status: SnapshotStatus.LIMITED_RESNAPSHOT,
|
|
576
|
+
snapshotLSN: status.checkpoint_lsn,
|
|
577
|
+
specificTablesToResnapshot: Array.from(additionalTablesToSnapshot)
|
|
578
|
+
};
|
|
579
|
+
} else {
|
|
580
|
+
return {
|
|
581
|
+
status: SnapshotStatus.DONE,
|
|
582
|
+
snapshotLSN: null
|
|
583
|
+
};
|
|
553
584
|
}
|
|
554
|
-
return { status: isAvailable ? SnapshotStatus.DONE : SnapshotStatus.RESTART_REQUIRED, snapshotLSN: null };
|
|
555
585
|
} else {
|
|
556
|
-
return {
|
|
586
|
+
return {
|
|
587
|
+
status: status.snapshot_lsn != null ? SnapshotStatus.RESUME : SnapshotStatus.INITIAL,
|
|
588
|
+
snapshotLSN: status.snapshot_lsn
|
|
589
|
+
};
|
|
557
590
|
}
|
|
558
591
|
}
|
|
559
592
|
|
|
@@ -571,16 +604,17 @@ export class CDCStream {
|
|
|
571
604
|
throw new ReplicationAssertionError(`No LSN found to resume replication from.`);
|
|
572
605
|
}
|
|
573
606
|
const startLSN = LSN.fromString(batch.resumeFromLsn);
|
|
574
|
-
const sourceTables: MSSQLSourceTable[] = this.tableCache.getAll();
|
|
575
607
|
const eventHandler = this.createEventHandler(batch);
|
|
576
608
|
|
|
577
609
|
const poller = new CDCPoller({
|
|
578
610
|
connectionManager: this.connections,
|
|
579
611
|
eventHandler,
|
|
580
|
-
|
|
612
|
+
getReplicatedTables: () => this.tableCache.getAll(),
|
|
613
|
+
sourceTables: this.syncRules.getSourceTables(),
|
|
581
614
|
startLSN,
|
|
582
615
|
logger: this.logger,
|
|
583
|
-
additionalConfig: this.options.additionalConfig
|
|
616
|
+
additionalConfig: this.options.additionalConfig,
|
|
617
|
+
schemaCheckIntervalMs: this.options.schemaCheckIntervalMs
|
|
584
618
|
});
|
|
585
619
|
|
|
586
620
|
this.abortSignal.addEventListener(
|
|
@@ -639,16 +673,125 @@ export class CDCStream {
|
|
|
639
673
|
this.metrics.getCounter(ReplicationMetric.ROWS_REPLICATED).add(1);
|
|
640
674
|
},
|
|
641
675
|
onCommit: async (lsn: string, transactionCount: number) => {
|
|
642
|
-
await batch.commit(lsn
|
|
676
|
+
const { checkpointBlocked } = await batch.commit(lsn, {
|
|
677
|
+
oldestUncommittedChange: this.replicationLag.oldestUncommittedChange
|
|
678
|
+
});
|
|
643
679
|
this.metrics.getCounter(ReplicationMetric.TRANSACTIONS_REPLICATED).add(transactionCount);
|
|
644
|
-
|
|
680
|
+
if (!checkpointBlocked) {
|
|
681
|
+
this.replicationLag.markCommitted();
|
|
682
|
+
}
|
|
645
683
|
},
|
|
646
|
-
onSchemaChange: async () => {
|
|
647
|
-
|
|
684
|
+
onSchemaChange: async (schemaChange: SchemaChange) => {
|
|
685
|
+
await this.handleSchemaChange(batch, schemaChange);
|
|
648
686
|
}
|
|
649
687
|
};
|
|
650
688
|
}
|
|
651
689
|
|
|
690
|
+
async handleSchemaChange(batch: storage.BucketStorageBatch, change: SchemaChange): Promise<void> {
|
|
691
|
+
let actionedSchemaChange = true;
|
|
692
|
+
|
|
693
|
+
switch (change.type) {
|
|
694
|
+
case SchemaChangeType.TABLE_RENAME:
|
|
695
|
+
const fromTable = change.table!;
|
|
696
|
+
this.logger.info(
|
|
697
|
+
`Table ${fromTable.toQualifiedName()} has been renamed ${change.newTable ? `to [${change.newTable.name}].` : '.'}`
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// Old table needs to be cleaned up
|
|
701
|
+
await batch.drop([fromTable.sourceTable]);
|
|
702
|
+
this.tableCache.delete(fromTable.objectId);
|
|
703
|
+
|
|
704
|
+
if (change.newTable) {
|
|
705
|
+
await this.handleCreateOrUpdateTable(batch, change.newTable, change.newCaptureInstance!);
|
|
706
|
+
}
|
|
707
|
+
break;
|
|
708
|
+
case SchemaChangeType.TABLE_CREATE:
|
|
709
|
+
await this.handleCreateOrUpdateTable(batch, change.newTable!, change.newCaptureInstance!);
|
|
710
|
+
break;
|
|
711
|
+
case SchemaChangeType.TABLE_COLUMN_CHANGES:
|
|
712
|
+
await this.handleColumnChanges(change.table!, change.newCaptureInstance!);
|
|
713
|
+
actionedSchemaChange = false;
|
|
714
|
+
break;
|
|
715
|
+
case SchemaChangeType.NEW_CAPTURE_INSTANCE:
|
|
716
|
+
this.logger.info(
|
|
717
|
+
`New CDC capture instance detected for table ${change.table!.toQualifiedName()}. Re-snapshotting table...`
|
|
718
|
+
);
|
|
719
|
+
await batch.drop([change.table!.sourceTable]);
|
|
720
|
+
this.tableCache.delete(change.table!.objectId);
|
|
721
|
+
|
|
722
|
+
await this.handleCreateOrUpdateTable(batch, change.table!.sourceTable, change.newCaptureInstance!);
|
|
723
|
+
break;
|
|
724
|
+
case SchemaChangeType.TABLE_DROP:
|
|
725
|
+
await batch.drop([change.table!.sourceTable]);
|
|
726
|
+
this.tableCache.delete(change.table!.objectId);
|
|
727
|
+
break;
|
|
728
|
+
case SchemaChangeType.MISSING_CAPTURE_INSTANCE:
|
|
729
|
+
// Stop replication for this table until CDC is re-enabled.
|
|
730
|
+
this.logger.warn(
|
|
731
|
+
`Table ${change.table!.toQualifiedName()} has been disabled for CDC. Re-enable CDC to continue replication.`
|
|
732
|
+
);
|
|
733
|
+
change.table!.clearCaptureInstance();
|
|
734
|
+
actionedSchemaChange = false;
|
|
735
|
+
break;
|
|
736
|
+
default:
|
|
737
|
+
throw new ReplicationAssertionError(`Unknown schema change type: ${change.type}`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Create a new checkpoint after the schema change
|
|
741
|
+
if (actionedSchemaChange) {
|
|
742
|
+
await createCheckpoint(this.connections);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private async handleCreateOrUpdateTable(
|
|
747
|
+
batch: storage.BucketStorageBatch,
|
|
748
|
+
table: Omit<SourceEntityDescriptor, 'replicaIdColumns'>,
|
|
749
|
+
captureInstance: CaptureInstance
|
|
750
|
+
): Promise<void> {
|
|
751
|
+
const replicaIdColumns = await getReplicationIdentityColumns({
|
|
752
|
+
connectionManager: this.connections,
|
|
753
|
+
tableName: table.name,
|
|
754
|
+
schema: table.schema
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
await this.processTable(
|
|
758
|
+
batch,
|
|
759
|
+
{
|
|
760
|
+
name: table.name,
|
|
761
|
+
schema: table.schema,
|
|
762
|
+
objectId: table.objectId,
|
|
763
|
+
replicaIdColumns: replicaIdColumns.columns
|
|
764
|
+
},
|
|
765
|
+
captureInstance,
|
|
766
|
+
true
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* There is very little that can be automatically done to handle the column changes other than to warn the user about the schema drift.
|
|
772
|
+
*
|
|
773
|
+
* Due to the way CDC works, users are prevented from making column schema changes that affect the replication identities of a table.
|
|
774
|
+
* If changes like that are required, CDC has to be disabled and re-enabled for the table. This would then be handled by the detection of the new
|
|
775
|
+
* capture instance.
|
|
776
|
+
* @param table
|
|
777
|
+
* @param captureInstance
|
|
778
|
+
*/
|
|
779
|
+
private async handleColumnChanges(table: MSSQLSourceTable, captureInstance: CaptureInstance): Promise<void> {
|
|
780
|
+
// Check there are any new pending schema changes
|
|
781
|
+
if (
|
|
782
|
+
table.captureInstance?.objectId === captureInstance.objectId &&
|
|
783
|
+
table.captureInstance?.pendingSchemaChanges.length === captureInstance.pendingSchemaChanges.length
|
|
784
|
+
) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// New pending schema changes were detected - warn about those as well.
|
|
789
|
+
this.logger.warn(
|
|
790
|
+
`Schema drift detected for table ${table.toQualifiedName()}. To ensure consistency, disable and re-enable CDC for this table.\n Pending schema changes:\n ${captureInstance.pendingSchemaChanges.join(', \n')}`
|
|
791
|
+
);
|
|
792
|
+
table.captureInstance = captureInstance;
|
|
793
|
+
}
|
|
794
|
+
|
|
652
795
|
/**
|
|
653
796
|
* Convert CDC row data to SqliteRow format.
|
|
654
797
|
* CDC rows include table columns plus CDC metadata columns (__$operation, __$start_lsn, etc.).
|
|
@@ -660,17 +803,8 @@ export class CDCStream {
|
|
|
660
803
|
return this.syncRules.applyRowContext<never>(inputRow);
|
|
661
804
|
}
|
|
662
805
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (this.isStartingReplication) {
|
|
666
|
-
// We don't have anything to compute replication lag with yet.
|
|
667
|
-
return undefined;
|
|
668
|
-
} else {
|
|
669
|
-
// We don't have any uncommitted changes, so replication is up-to-date.
|
|
670
|
-
return 0;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
return Date.now() - this.oldestUncommittedChange.getTime();
|
|
806
|
+
getReplicationLagMillis(): number | undefined {
|
|
807
|
+
return this.replicationLag.getLagMillis();
|
|
674
808
|
}
|
|
675
809
|
|
|
676
810
|
private touch() {
|
|
@@ -678,4 +812,13 @@ export class CDCStream {
|
|
|
678
812
|
this.logger.error(`Error touching probe`, e);
|
|
679
813
|
});
|
|
680
814
|
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Creates an update in the source database to ensure regular checkpoints via the CDC
|
|
818
|
+
*/
|
|
819
|
+
public async keepAlive() {
|
|
820
|
+
if (!this.replicationLag.isStartingReplication && !this.stopped) {
|
|
821
|
+
await createCheckpoint(this.connections);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
681
824
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { BaseObserver, logger } from '@powersync/lib-services-framework';
|
|
2
|
-
import sql from 'mssql';
|
|
3
|
-
import { NormalizedMSSQLConnectionConfig } from '../types/types.js';
|
|
4
2
|
import { POWERSYNC_VERSION } from '@powersync/service-core';
|
|
3
|
+
import sql from 'mssql';
|
|
5
4
|
import { MSSQLParameter } from '../types/mssql-data-types.js';
|
|
5
|
+
import { NormalizedMSSQLConnectionConfig } from '../types/types.js';
|
|
6
6
|
import { addParameters } from '../utils/mssql.js';
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_SCHEMA = 'dbo';
|
|
@@ -87,13 +87,23 @@ export class MSSQLConnectionManager extends BaseObserver<MSSQLConnectionManagerL
|
|
|
87
87
|
|
|
88
88
|
async execute(procedure: string, parameters?: MSSQLParameter[]): Promise<sql.IProcedureResult<any>> {
|
|
89
89
|
await this.ensureConnected();
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
request =
|
|
90
|
+
for (let tries = 2; ; tries--) {
|
|
91
|
+
try {
|
|
92
|
+
logger.debug(`Executing procedure: ${procedure}`);
|
|
93
|
+
let request = this.pool.request();
|
|
94
|
+
if (parameters) {
|
|
95
|
+
if (parameters) {
|
|
96
|
+
request = addParameters(request, parameters);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return request.execute(procedure);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (tries == 1) {
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
logger.warn(`Error executing stored procedure: ${procedure}, retrying..`, e);
|
|
94
105
|
}
|
|
95
106
|
}
|
|
96
|
-
return request.execute(procedure);
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
async end(): Promise<void> {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { logger } from '@powersync/lib-services-framework';
|
|
2
|
+
import sql from 'mssql';
|
|
2
3
|
import { ResolvedMSSQLConnectionConfig } from '../types/types.js';
|
|
3
4
|
import { MSSQLConnectionManager } from './MSSQLConnectionManager.js';
|
|
4
|
-
import sql from 'mssql';
|
|
5
5
|
|
|
6
6
|
export class MSSQLConnectionManagerFactory {
|
|
7
7
|
private readonly connectionManagers: Set<MSSQLConnectionManager>;
|