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