@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# @powersync/service-module-mssql
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8d5d7ee: Added schema change detection and handling for the SQL Server adapter
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [224c35e]
|
|
12
|
+
- Updated dependencies [acf1486]
|
|
13
|
+
- Updated dependencies [391c5ef]
|
|
14
|
+
- Updated dependencies [7ee87d4]
|
|
15
|
+
- Updated dependencies [99de8be]
|
|
16
|
+
- Updated dependencies [8d5d7ee]
|
|
17
|
+
- Updated dependencies [9daf965]
|
|
18
|
+
- Updated dependencies [4c92c24]
|
|
19
|
+
- Updated dependencies [3d230c2]
|
|
20
|
+
- Updated dependencies [206633f]
|
|
21
|
+
- Updated dependencies [3a0627e]
|
|
22
|
+
- Updated dependencies [275fd5f]
|
|
23
|
+
- Updated dependencies [7ce1b8e]
|
|
24
|
+
- @powersync/service-sync-rules@0.34.0
|
|
25
|
+
- @powersync/service-core@1.20.2
|
|
26
|
+
- @powersync/service-errors@0.4.1
|
|
27
|
+
- @powersync/lib-services-framework@0.9.1
|
|
28
|
+
|
|
29
|
+
## 0.5.0
|
|
30
|
+
|
|
31
|
+
### Minor Changes
|
|
32
|
+
|
|
33
|
+
- c15efc7: [Internal] Track and propagate source on buckets and parameter indexes to storage APIs.
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- Updated dependencies [8c5bb3b]
|
|
38
|
+
- Updated dependencies [dcddcf1]
|
|
39
|
+
- Updated dependencies [c15efc7]
|
|
40
|
+
- Updated dependencies [e7152ce]
|
|
41
|
+
- Updated dependencies [e150c5c]
|
|
42
|
+
- Updated dependencies [b410924]
|
|
43
|
+
- @powersync/service-errors@0.4.0
|
|
44
|
+
- @powersync/service-core@1.20.1
|
|
45
|
+
- @powersync/lib-services-framework@0.9.0
|
|
46
|
+
- @powersync/service-sync-rules@0.33.0
|
|
47
|
+
|
|
3
48
|
## 0.4.0
|
|
4
49
|
|
|
5
50
|
### Minor Changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { LSN } from './LSN.js';
|
|
2
|
+
export interface CaptureInstance {
|
|
3
|
+
name: string;
|
|
4
|
+
/**
|
|
5
|
+
* Object ID for the capture instance table
|
|
6
|
+
*/
|
|
7
|
+
objectId: number;
|
|
8
|
+
minLSN: LSN;
|
|
9
|
+
createDate: Date;
|
|
10
|
+
/**
|
|
11
|
+
* DDL commands that have been applied to the source table but are not reflected in the capture instance.
|
|
12
|
+
*/
|
|
13
|
+
pendingSchemaChanges: string[];
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CaptureInstance.js","sourceRoot":"","sources":["../../src/common/CaptureInstance.ts"],"names":[],"mappings":""}
|
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
import { SourceTable } from '@powersync/service-core';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export
|
|
2
|
+
import { CaptureInstance } from './CaptureInstance.js';
|
|
3
|
+
/**
|
|
4
|
+
* The cdc schema in SQL Server is reserved and created when enabling CDC on a database.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CDC_SCHEMA = "cdc";
|
|
7
|
+
export declare class MSSQLSourceTable {
|
|
7
8
|
sourceTable: SourceTable;
|
|
8
9
|
/**
|
|
9
10
|
* The unique name of the CDC capture instance for this table
|
|
10
11
|
*/
|
|
11
|
-
captureInstance: CaptureInstance;
|
|
12
|
-
|
|
13
|
-
export declare class MSSQLSourceTable {
|
|
14
|
-
private options;
|
|
15
|
-
constructor(options: MSSQLSourceTableOptions);
|
|
16
|
-
get sourceTable(): SourceTable;
|
|
12
|
+
captureInstance: CaptureInstance | null;
|
|
13
|
+
constructor(sourceTable: SourceTable);
|
|
17
14
|
updateSourceTable(updated: SourceTable): void;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
enabledForCDC(): boolean;
|
|
16
|
+
setCaptureInstance(captureInstance: CaptureInstance): void;
|
|
17
|
+
clearCaptureInstance(): void;
|
|
21
18
|
get allChangesFunction(): string;
|
|
22
19
|
get netChangesFunction(): string;
|
|
20
|
+
/**
|
|
21
|
+
* Return the object ID of the source table.
|
|
22
|
+
* Object IDs in SQL Server are always numbers.
|
|
23
|
+
*/
|
|
24
|
+
get objectId(): number;
|
|
23
25
|
/**
|
|
24
26
|
* Escapes this source table's name and schema for use in MSSQL queries.
|
|
25
27
|
*/
|
|
@@ -1,29 +1,48 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { toQualifiedTableName } from '../utils/mssql.js';
|
|
2
|
+
import { ServiceAssertionError } from '@powersync/service-errors';
|
|
3
|
+
/**
|
|
4
|
+
* The cdc schema in SQL Server is reserved and created when enabling CDC on a database.
|
|
5
|
+
*/
|
|
6
|
+
export const CDC_SCHEMA = 'cdc';
|
|
2
7
|
export class MSSQLSourceTable {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
sourceTable;
|
|
9
|
+
/**
|
|
10
|
+
* The unique name of the CDC capture instance for this table
|
|
11
|
+
*/
|
|
12
|
+
captureInstance = null;
|
|
13
|
+
constructor(sourceTable) {
|
|
14
|
+
this.sourceTable = sourceTable;
|
|
9
15
|
}
|
|
10
16
|
updateSourceTable(updated) {
|
|
11
|
-
this.
|
|
17
|
+
this.sourceTable = updated;
|
|
12
18
|
}
|
|
13
|
-
|
|
14
|
-
return this.
|
|
19
|
+
enabledForCDC() {
|
|
20
|
+
return this.captureInstance !== null;
|
|
15
21
|
}
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
setCaptureInstance(captureInstance) {
|
|
23
|
+
this.captureInstance = captureInstance;
|
|
18
24
|
}
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
clearCaptureInstance() {
|
|
26
|
+
this.captureInstance = null;
|
|
21
27
|
}
|
|
22
28
|
get allChangesFunction() {
|
|
23
|
-
|
|
29
|
+
if (!this.captureInstance) {
|
|
30
|
+
throw new ServiceAssertionError(`No capture instance set for table: ${this.sourceTable.name}`);
|
|
31
|
+
}
|
|
32
|
+
return `${CDC_SCHEMA}.fn_cdc_get_all_changes_${this.captureInstance.name}`;
|
|
24
33
|
}
|
|
25
34
|
get netChangesFunction() {
|
|
26
|
-
|
|
35
|
+
if (!this.captureInstance) {
|
|
36
|
+
throw new ServiceAssertionError(`No capture instance set for table: ${this.sourceTable.name}`);
|
|
37
|
+
}
|
|
38
|
+
return `${CDC_SCHEMA}.fn_cdc_get_net_changes_${this.captureInstance.name}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Return the object ID of the source table.
|
|
42
|
+
* Object IDs in SQL Server are always numbers.
|
|
43
|
+
*/
|
|
44
|
+
get objectId() {
|
|
45
|
+
return this.sourceTable.objectId;
|
|
27
46
|
}
|
|
28
47
|
/**
|
|
29
48
|
* Escapes this source table's name and schema for use in MSSQL queries.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MSSQLSourceTable.js","sourceRoot":"","sources":["../../src/common/MSSQLSourceTable.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"MSSQLSourceTable.js","sourceRoot":"","sources":["../../src/common/MSSQLSourceTable.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAGlE;;GAEG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,CAAC;AAEhC,MAAM,OAAO,gBAAgB;IAMR;IALnB;;OAEG;IACI,eAAe,GAA2B,IAAI,CAAC;IAEtD,YAAmB,WAAwB;QAAxB,gBAAW,GAAX,WAAW,CAAa;IAAG,CAAC;IAE/C,iBAAiB,CAAC,OAAoB;QACpC,IAAI,CAAC,WAAW,GAAG,OAAO,CAAC;IAC7B,CAAC;IAED,aAAa;QACX,OAAO,IAAI,CAAC,eAAe,KAAK,IAAI,CAAC;IACvC,CAAC;IAED,kBAAkB,CAAC,eAAgC;QACjD,IAAI,CAAC,eAAe,GAAG,eAAe,CAAC;IACzC,CAAC;IAED,oBAAoB;QAClB,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC;IAC9B,CAAC;IAED,IAAI,kBAAkB;QACpB,IAAI,CAAC,IAAI,CAAC,eAAe,EAAE,CAAC;YAC1B,MAAM,IAAI,qBAAqB,CAAC,sCAAsC,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC;QACjG,CAAC;QACD,OAAO,GAAG,UAAU,2BAA2B,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;IAC7E,CAAC;IAED,IAAI,kBAAkB;QACpB,IAAI,CAAC,IAAI,CAAC,eAAe,EAAE,CAAC;YAC1B,MAAM,IAAI,qBAAqB,CAAC,sCAAsC,IAAI,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC;QACjG,CAAC;QACD,OAAO,GAAG,UAAU,2BAA2B,IAAI,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC;IAC7E,CAAC;IAED;;;OAGG;IACH,IAAI,QAAQ;QACV,OAAO,IAAI,CAAC,WAAW,CAAC,QAAkB,CAAC;IAC7C,CAAC;IAED;;OAEG;IACH,eAAe;QACb,OAAO,oBAAoB,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;IAC9E,CAAC;CACF"}
|
|
@@ -4,44 +4,55 @@ import { MSSQLSourceTable } from '../common/MSSQLSourceTable.js';
|
|
|
4
4
|
import { LSN } from '../common/LSN.js';
|
|
5
5
|
import sql from 'mssql';
|
|
6
6
|
import { AdditionalConfig } from '../types/types.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* are received for them.
|
|
11
|
-
*/
|
|
7
|
+
import { TablePattern } from '@powersync/service-sync-rules';
|
|
8
|
+
import { CaptureInstance } from '../common/CaptureInstance.js';
|
|
9
|
+
import { SourceEntityDescriptor } from '@powersync/service-core';
|
|
12
10
|
export declare enum SchemaChangeType {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
TABLE_RENAME = "table_rename",
|
|
12
|
+
TABLE_DROP = "table_drop",
|
|
13
|
+
TABLE_CREATE = "table_create",
|
|
14
|
+
TABLE_COLUMN_CHANGES = "table_column_changes",
|
|
15
|
+
NEW_CAPTURE_INSTANCE = "new_capture_instance",
|
|
16
|
+
MISSING_CAPTURE_INSTANCE = "missing_capture_instance"
|
|
18
17
|
}
|
|
19
18
|
export interface SchemaChange {
|
|
20
19
|
type: SchemaChangeType;
|
|
21
20
|
/**
|
|
22
|
-
* The table that the schema change applies to.
|
|
21
|
+
* The table that the schema change applies to. Populated for table drops, renames, new capture instances, and DDL changes.
|
|
23
22
|
*/
|
|
24
|
-
table
|
|
25
|
-
schema: string;
|
|
23
|
+
table?: MSSQLSourceTable;
|
|
26
24
|
/**
|
|
27
|
-
* Populated for
|
|
25
|
+
* Populated for new tables or renames, but only if the new table matches a sync rule source table.
|
|
28
26
|
*/
|
|
29
|
-
newTable?:
|
|
27
|
+
newTable?: Omit<SourceEntityDescriptor, 'replicaIdColumns'>;
|
|
28
|
+
newCaptureInstance?: CaptureInstance;
|
|
30
29
|
}
|
|
31
30
|
export interface CDCEventHandler {
|
|
32
|
-
onInsert: (row: any, table: MSSQLSourceTable,
|
|
33
|
-
onUpdate: (rowAfter: any, rowBefore: any, table: MSSQLSourceTable,
|
|
34
|
-
onDelete: (row: any, table: MSSQLSourceTable,
|
|
31
|
+
onInsert: (row: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
|
|
32
|
+
onUpdate: (rowAfter: any, rowBefore: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
|
|
33
|
+
onDelete: (row: any, table: MSSQLSourceTable, columns: sql.IColumnMetadata) => Promise<void>;
|
|
35
34
|
onCommit: (lsn: string, transactionCount: number) => Promise<void>;
|
|
36
35
|
onSchemaChange: (change: SchemaChange) => Promise<void>;
|
|
37
36
|
}
|
|
37
|
+
export declare const DEFAULT_SCHEMA_CHECK_INTERVAL_MS = 60000;
|
|
38
38
|
export interface CDCPollerOptions {
|
|
39
39
|
connectionManager: MSSQLConnectionManager;
|
|
40
40
|
eventHandler: CDCEventHandler;
|
|
41
|
-
|
|
41
|
+
/** CDC enabled source tables from the sync rules to replicate */
|
|
42
|
+
getReplicatedTables: () => MSSQLSourceTable[];
|
|
43
|
+
/** All table patterns from the sync rules. Can contain tables that need to be replicated
|
|
44
|
+
* but do not yet have CDC enabled
|
|
45
|
+
*/
|
|
46
|
+
sourceTables: TablePattern[];
|
|
42
47
|
startLSN: LSN;
|
|
43
48
|
logger?: Logger;
|
|
44
49
|
additionalConfig: AdditionalConfig;
|
|
50
|
+
/**
|
|
51
|
+
* Interval in milliseconds between schema change checks.
|
|
52
|
+
* Schema checks also run immediately after a recoverable error during polling
|
|
53
|
+
* (e.g. a dropped capture instance).
|
|
54
|
+
*/
|
|
55
|
+
schemaCheckIntervalMs?: number;
|
|
45
56
|
}
|
|
46
57
|
/**
|
|
47
58
|
*
|
|
@@ -53,15 +64,26 @@ export declare class CDCPoller {
|
|
|
53
64
|
private currentLSN;
|
|
54
65
|
private logger;
|
|
55
66
|
private listenerError;
|
|
67
|
+
private captureInstances;
|
|
56
68
|
private isStopped;
|
|
57
69
|
private isStopping;
|
|
58
70
|
private isPolling;
|
|
71
|
+
private lastSchemaCheckTime;
|
|
59
72
|
constructor(options: CDCPollerOptions);
|
|
60
73
|
private get pollingBatchSize();
|
|
61
74
|
private get pollingIntervalMs();
|
|
62
|
-
private get
|
|
75
|
+
private get schemaCheckIntervalMs();
|
|
76
|
+
private get replicatedTables();
|
|
63
77
|
stop(): Promise<void>;
|
|
64
78
|
replicateUntilStopped(): Promise<void>;
|
|
65
79
|
private poll;
|
|
66
80
|
private pollTable;
|
|
81
|
+
private shouldCheckSchema;
|
|
82
|
+
/**
|
|
83
|
+
* Checks the given table for pending schema changes that can lead to inconsistencies in the replicated data if not handled.
|
|
84
|
+
* Returns the SchemaChange if any are found, null otherwise.
|
|
85
|
+
*/
|
|
86
|
+
private checkForSchemaChanges;
|
|
87
|
+
private checkForNewTables;
|
|
88
|
+
private tableMatchesSyncRules;
|
|
67
89
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { logger as defaultLogger, ReplicationAssertionError } from '@powersync/lib-services-framework';
|
|
1
|
+
import { DatabaseQueryError, ErrorCode, logger as defaultLogger, ReplicationAssertionError } from '@powersync/lib-services-framework';
|
|
2
2
|
import timers from 'timers/promises';
|
|
3
3
|
import { LSN } from '../common/LSN.js';
|
|
4
4
|
import sql from 'mssql';
|
|
5
|
-
import {
|
|
5
|
+
import { getCaptureInstances, incrementLSN, toQualifiedTableName } from '../utils/mssql.js';
|
|
6
|
+
import { isDeadlockError } from '../utils/deadlock.js';
|
|
7
|
+
import { tableExists } from '../utils/schema.js';
|
|
6
8
|
var Operation;
|
|
7
9
|
(function (Operation) {
|
|
8
10
|
Operation[Operation["DELETE"] = 1] = "DELETE";
|
|
@@ -10,19 +12,16 @@ var Operation;
|
|
|
10
12
|
Operation[Operation["UPDATE_BEFORE"] = 3] = "UPDATE_BEFORE";
|
|
11
13
|
Operation[Operation["UPDATE_AFTER"] = 4] = "UPDATE_AFTER";
|
|
12
14
|
})(Operation || (Operation = {}));
|
|
13
|
-
/**
|
|
14
|
-
* Schema changes that are detectable by inspecting query events.
|
|
15
|
-
* Create table statements are not included here, since new tables are automatically detected when row events
|
|
16
|
-
* are received for them.
|
|
17
|
-
*/
|
|
18
15
|
export var SchemaChangeType;
|
|
19
16
|
(function (SchemaChangeType) {
|
|
20
|
-
SchemaChangeType["
|
|
21
|
-
SchemaChangeType["
|
|
22
|
-
SchemaChangeType["
|
|
23
|
-
SchemaChangeType["
|
|
24
|
-
SchemaChangeType["
|
|
17
|
+
SchemaChangeType["TABLE_RENAME"] = "table_rename";
|
|
18
|
+
SchemaChangeType["TABLE_DROP"] = "table_drop";
|
|
19
|
+
SchemaChangeType["TABLE_CREATE"] = "table_create";
|
|
20
|
+
SchemaChangeType["TABLE_COLUMN_CHANGES"] = "table_column_changes";
|
|
21
|
+
SchemaChangeType["NEW_CAPTURE_INSTANCE"] = "new_capture_instance";
|
|
22
|
+
SchemaChangeType["MISSING_CAPTURE_INSTANCE"] = "missing_capture_instance";
|
|
25
23
|
})(SchemaChangeType || (SchemaChangeType = {}));
|
|
24
|
+
export const DEFAULT_SCHEMA_CHECK_INTERVAL_MS = 60_000;
|
|
26
25
|
/**
|
|
27
26
|
*
|
|
28
27
|
*/
|
|
@@ -33,9 +32,11 @@ export class CDCPoller {
|
|
|
33
32
|
currentLSN;
|
|
34
33
|
logger;
|
|
35
34
|
listenerError;
|
|
35
|
+
captureInstances;
|
|
36
36
|
isStopped = false;
|
|
37
37
|
isStopping = false;
|
|
38
38
|
isPolling = false;
|
|
39
|
+
lastSchemaCheckTime = 0;
|
|
39
40
|
constructor(options) {
|
|
40
41
|
this.options = options;
|
|
41
42
|
this.logger = options.logger ?? defaultLogger;
|
|
@@ -43,6 +44,7 @@ export class CDCPoller {
|
|
|
43
44
|
this.eventHandler = options.eventHandler;
|
|
44
45
|
this.currentLSN = options.startLSN;
|
|
45
46
|
this.listenerError = null;
|
|
47
|
+
this.captureInstances = new Map();
|
|
46
48
|
}
|
|
47
49
|
get pollingBatchSize() {
|
|
48
50
|
return this.options.additionalConfig.pollingBatchSize;
|
|
@@ -50,8 +52,11 @@ export class CDCPoller {
|
|
|
50
52
|
get pollingIntervalMs() {
|
|
51
53
|
return this.options.additionalConfig.pollingIntervalMs;
|
|
52
54
|
}
|
|
53
|
-
get
|
|
54
|
-
return this.options.
|
|
55
|
+
get schemaCheckIntervalMs() {
|
|
56
|
+
return this.options.schemaCheckIntervalMs ?? DEFAULT_SCHEMA_CHECK_INTERVAL_MS;
|
|
57
|
+
}
|
|
58
|
+
get replicatedTables() {
|
|
59
|
+
return this.options.getReplicatedTables();
|
|
55
60
|
}
|
|
56
61
|
async stop() {
|
|
57
62
|
if (!(this.isStopped || this.isStopping)) {
|
|
@@ -68,15 +73,38 @@ export class CDCPoller {
|
|
|
68
73
|
throw new ReplicationAssertionError('A polling cycle is already in progress.');
|
|
69
74
|
}
|
|
70
75
|
try {
|
|
76
|
+
if (this.shouldCheckSchema()) {
|
|
77
|
+
this.captureInstances = await getCaptureInstances({ connectionManager: this.connectionManager });
|
|
78
|
+
const schemaChanges = await this.checkForSchemaChanges();
|
|
79
|
+
for (const schemaChange of schemaChanges) {
|
|
80
|
+
await this.eventHandler.onSchemaChange(schemaChange);
|
|
81
|
+
}
|
|
82
|
+
this.lastSchemaCheckTime = Date.now();
|
|
83
|
+
this.logger.debug(`Schema change check complete. Schema changes found: ${schemaChanges.map((c) => c.type).join(', ')}`);
|
|
84
|
+
}
|
|
71
85
|
const hasChanges = await this.poll();
|
|
72
86
|
if (!hasChanges) {
|
|
73
|
-
// No changes found, wait before
|
|
87
|
+
// No changes found, wait before polling again
|
|
74
88
|
await timers.setTimeout(this.pollingIntervalMs);
|
|
75
89
|
}
|
|
76
90
|
// If changes were found, poll immediately again (no wait)
|
|
77
91
|
}
|
|
78
92
|
catch (error) {
|
|
79
93
|
if (!(this.isStopped || this.isStopping)) {
|
|
94
|
+
// Recoverable errors
|
|
95
|
+
if (error instanceof DatabaseQueryError) {
|
|
96
|
+
this.logger.warn(error.message);
|
|
97
|
+
// Force schema check on next iteration to detect breaking changes
|
|
98
|
+
this.lastSchemaCheckTime = 0;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Deadlock errors are transient — even if all retries within retryOnDeadlock were
|
|
102
|
+
// exhausted, we should not crash the poller. Instead, log and retry the entire cycle.
|
|
103
|
+
if (isDeadlockError(error)) {
|
|
104
|
+
this.logger.warn(`Deadlock persisted after all retry attempts during CDC polling cycle. Will retry on next cycle: ${error.message}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Non-recoverable errors
|
|
80
108
|
this.listenerError = error;
|
|
81
109
|
this.logger.error('Error during CDC polling:', error);
|
|
82
110
|
this.stop();
|
|
@@ -110,15 +138,18 @@ export class CDCPoller {
|
|
|
110
138
|
const endLSN = LSN.fromBinary(results[results.length - 1].start_lsn);
|
|
111
139
|
this.logger.info(`Polling bounds are ${startLSN} -> ${endLSN} spanning ${results.length} transaction(s).`);
|
|
112
140
|
let transactionCount = 0;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
141
|
+
this.logger.debug(`Currently replicating tables: ${this.replicatedTables.map((table) => table.toQualifiedName()).join(', ')}`);
|
|
142
|
+
for (const table of this.replicatedTables) {
|
|
143
|
+
if (table.enabledForCDC()) {
|
|
144
|
+
const tableTransactionCount = await this.pollTable(table, { startLSN, endLSN });
|
|
145
|
+
// We poll for batch size transactions, but these include transactions not applicable to our Source Tables.
|
|
146
|
+
// Each Source Table may or may not have transactions that are applicable to it, so just keep track of the highest number of transactions processed for any Source Table.
|
|
147
|
+
if (tableTransactionCount > transactionCount) {
|
|
148
|
+
transactionCount = tableTransactionCount;
|
|
149
|
+
}
|
|
119
150
|
}
|
|
120
151
|
}
|
|
121
|
-
this.logger.info(`Processed ${results.length} transaction(s), including ${transactionCount} Source Table transaction(s)
|
|
152
|
+
this.logger.info(`Processed ${results.length} transaction(s), including ${transactionCount} Source Table transaction(s). Commited LSN: ${endLSN.toString()}`);
|
|
122
153
|
// Call eventHandler.onCommit() with toLSN after processing all tables
|
|
123
154
|
await this.eventHandler.onCommit(endLSN.toString(), transactionCount);
|
|
124
155
|
this.currentLSN = endLSN;
|
|
@@ -131,57 +162,166 @@ export class CDCPoller {
|
|
|
131
162
|
}
|
|
132
163
|
async pollTable(table, bounds) {
|
|
133
164
|
// Ensure that the startLSN is not before the minimum LSN for the table
|
|
134
|
-
const minLSN =
|
|
165
|
+
const minLSN = this.captureInstances.get(table.objectId).instances[0].minLSN;
|
|
135
166
|
if (minLSN > bounds.endLSN) {
|
|
136
167
|
return 0;
|
|
137
168
|
}
|
|
138
169
|
else if (minLSN >= bounds.startLSN) {
|
|
139
170
|
bounds.startLSN = minLSN;
|
|
140
171
|
}
|
|
141
|
-
|
|
172
|
+
try {
|
|
173
|
+
const { recordset: results } = await this.connectionManager.query(`
|
|
142
174
|
SELECT * FROM ${table.allChangesFunction}(@from_lsn, @to_lsn, 'all update old') ORDER BY __$start_lsn, __$seqval
|
|
143
175
|
`, [
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
176
|
+
{ name: 'from_lsn', type: sql.VarBinary, value: bounds.startLSN.toBinary() },
|
|
177
|
+
{ name: 'to_lsn', type: sql.VarBinary, value: bounds.endLSN.toBinary() }
|
|
178
|
+
]);
|
|
179
|
+
let transactionCount = 0;
|
|
180
|
+
let updateBefore = null;
|
|
181
|
+
let lastTransactionLSN = null;
|
|
182
|
+
for (const row of results) {
|
|
183
|
+
const transactionLSN = LSN.fromBinary(row.__$start_lsn);
|
|
184
|
+
switch (row.__$operation) {
|
|
185
|
+
case Operation.DELETE:
|
|
186
|
+
await this.eventHandler.onDelete(row, table, results.columns);
|
|
187
|
+
this.logger.info(`Processed DELETE row LSN: ${transactionLSN}`);
|
|
188
|
+
break;
|
|
189
|
+
case Operation.INSERT:
|
|
190
|
+
await this.eventHandler.onInsert(row, table, results.columns);
|
|
191
|
+
this.logger.info(`Processed INSERT row LSN: ${transactionLSN}`);
|
|
192
|
+
break;
|
|
193
|
+
case Operation.UPDATE_BEFORE:
|
|
194
|
+
updateBefore = row;
|
|
195
|
+
this.logger.debug(`Processed UPDATE, before row LSN: ${transactionLSN}`);
|
|
196
|
+
break;
|
|
197
|
+
case Operation.UPDATE_AFTER:
|
|
198
|
+
if (updateBefore === null) {
|
|
199
|
+
throw new ReplicationAssertionError('Missing before image for update event.');
|
|
200
|
+
}
|
|
201
|
+
await this.eventHandler.onUpdate(row, updateBefore, table, results.columns);
|
|
202
|
+
updateBefore = null;
|
|
203
|
+
this.logger.info(`Processed UPDATE row LSN: ${transactionLSN}`);
|
|
204
|
+
break;
|
|
205
|
+
default:
|
|
206
|
+
this.logger.warn(`Unknown operation type [${row.__$operation}] encountered in CDC changes.`);
|
|
207
|
+
}
|
|
208
|
+
// Increment transaction count when we encounter a new transaction LSN (except for UPDATE_BEFORE rows)
|
|
209
|
+
if (transactionLSN != lastTransactionLSN) {
|
|
210
|
+
lastTransactionLSN = transactionLSN;
|
|
211
|
+
if (row.__$operation !== Operation.UPDATE_BEFORE) {
|
|
212
|
+
transactionCount++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return transactionCount;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
// This Covers both deleted tables and capture instances
|
|
220
|
+
if (error.message.includes(`Invalid object name`)) {
|
|
221
|
+
throw new DatabaseQueryError(ErrorCode.PSYNC_S1601, `Capture instance for table ${table.toQualifiedName()} is no longer available.`, error);
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
shouldCheckSchema() {
|
|
227
|
+
return Date.now() - this.lastSchemaCheckTime >= this.schemaCheckIntervalMs;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Checks the given table for pending schema changes that can lead to inconsistencies in the replicated data if not handled.
|
|
231
|
+
* Returns the SchemaChange if any are found, null otherwise.
|
|
232
|
+
*/
|
|
233
|
+
async checkForSchemaChanges() {
|
|
234
|
+
const schemaChanges = [];
|
|
235
|
+
const newTables = this.checkForNewTables();
|
|
236
|
+
for (const table of newTables) {
|
|
237
|
+
this.logger.info(`New table ${toQualifiedTableName(table.sourceTable.schema, table.sourceTable.name)} matching the sync rules has been created. Handling schema change...`);
|
|
238
|
+
schemaChanges.push({
|
|
239
|
+
type: SchemaChangeType.TABLE_CREATE,
|
|
240
|
+
newTable: {
|
|
241
|
+
name: table.sourceTable.name,
|
|
242
|
+
schema: table.sourceTable.schema,
|
|
243
|
+
objectId: table.sourceTable.objectId
|
|
244
|
+
},
|
|
245
|
+
newCaptureInstance: table.instances[0]
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
for (const table of this.replicatedTables) {
|
|
249
|
+
const exists = await tableExists(table.objectId, this.connectionManager);
|
|
250
|
+
if (!exists) {
|
|
251
|
+
this.logger.info(`Table ${table.toQualifiedName()} has been dropped. Handling schema change...`);
|
|
252
|
+
schemaChanges.push({
|
|
253
|
+
type: SchemaChangeType.TABLE_DROP,
|
|
254
|
+
table
|
|
255
|
+
});
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const captureInstanceDetails = this.captureInstances.get(table.objectId);
|
|
259
|
+
if (!captureInstanceDetails) {
|
|
260
|
+
if (table.enabledForCDC()) {
|
|
261
|
+
// Table had a capture instance but no longer does.
|
|
262
|
+
schemaChanges.push({
|
|
263
|
+
type: SchemaChangeType.MISSING_CAPTURE_INSTANCE,
|
|
264
|
+
table
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const latestCaptureInstance = captureInstanceDetails.instances[0];
|
|
270
|
+
// If the table is not enabled for CDC or the capture instance is different, we need to re-snapshot the source table
|
|
271
|
+
if (!table.enabledForCDC() || table.captureInstance.objectId !== latestCaptureInstance.objectId) {
|
|
272
|
+
schemaChanges.push({
|
|
273
|
+
type: SchemaChangeType.NEW_CAPTURE_INSTANCE,
|
|
274
|
+
table,
|
|
275
|
+
newCaptureInstance: latestCaptureInstance
|
|
276
|
+
});
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// One of the replicated tables has been renamed
|
|
280
|
+
if (table.sourceTable.name !== captureInstanceDetails.sourceTable.name) {
|
|
281
|
+
const newTable = this.tableMatchesSyncRules(captureInstanceDetails.sourceTable.schema, captureInstanceDetails.sourceTable.name)
|
|
282
|
+
? {
|
|
283
|
+
name: captureInstanceDetails.sourceTable.name,
|
|
284
|
+
schema: captureInstanceDetails.sourceTable.schema,
|
|
285
|
+
objectId: captureInstanceDetails.sourceTable.objectId
|
|
168
286
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
287
|
+
: undefined;
|
|
288
|
+
schemaChanges.push({
|
|
289
|
+
type: SchemaChangeType.TABLE_RENAME,
|
|
290
|
+
table,
|
|
291
|
+
newTable,
|
|
292
|
+
newCaptureInstance: latestCaptureInstance
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (latestCaptureInstance.pendingSchemaChanges.length > 0) {
|
|
297
|
+
schemaChanges.push({
|
|
298
|
+
type: SchemaChangeType.TABLE_COLUMN_CHANGES,
|
|
299
|
+
table,
|
|
300
|
+
newCaptureInstance: latestCaptureInstance
|
|
301
|
+
});
|
|
175
302
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
303
|
+
}
|
|
304
|
+
return schemaChanges;
|
|
305
|
+
}
|
|
306
|
+
checkForNewTables() {
|
|
307
|
+
const newTables = [];
|
|
308
|
+
for (const [objectId, captureInstanceDetails] of this.captureInstances.entries()) {
|
|
309
|
+
// If a source table is not in the replicated tables array, but a capture instance exists for it, it is potentially a new table to replicate.
|
|
310
|
+
if (!this.replicatedTables.some((table) => table.objectId === objectId)) {
|
|
311
|
+
// Check if the new table matches any of the sync rules source tables.
|
|
312
|
+
if (this.tableMatchesSyncRules(captureInstanceDetails.sourceTable.schema, captureInstanceDetails.sourceTable.name)) {
|
|
313
|
+
newTables.push(captureInstanceDetails);
|
|
181
314
|
}
|
|
182
315
|
}
|
|
183
316
|
}
|
|
184
|
-
return
|
|
317
|
+
return newTables;
|
|
318
|
+
}
|
|
319
|
+
tableMatchesSyncRules(schema, tableName) {
|
|
320
|
+
return this.options.sourceTables.some((tablePattern) => tablePattern.matches({
|
|
321
|
+
connectionTag: this.connectionManager.connectionTag,
|
|
322
|
+
schema: schema,
|
|
323
|
+
name: tableName
|
|
324
|
+
}));
|
|
185
325
|
}
|
|
186
326
|
}
|
|
187
327
|
//# sourceMappingURL=CDCPoller.js.map
|