@axinom/mosaic-db-common 0.24.3 → 0.25.0-rc.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/dist/common/types.d.ts +1 -0
- package/dist/common/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/replication/create-logical-replication-service.d.ts +68 -0
- package/dist/replication/create-logical-replication-service.d.ts.map +1 -0
- package/dist/replication/create-logical-replication-service.js +115 -0
- package/dist/replication/create-logical-replication-service.js.map +1 -0
- package/dist/replication/index.d.ts +2 -0
- package/dist/replication/index.d.ts.map +1 -0
- package/dist/replication/index.js +18 -0
- package/dist/replication/index.js.map +1 -0
- package/migrations/define/define-functions.sql +58 -0
- package/migrations/snippets/custom.code-snippets +18 -0
- package/package.json +3 -2
- package/src/common/types.ts +1 -0
- package/src/index.ts +1 -0
- package/src/replication/create-logical-replication-service.spec.ts +312 -0
- package/src/replication/create-logical-replication-service.ts +220 -0
- package/src/replication/index.ts +1 -0
package/dist/common/types.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/common/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAE1B;;GAEG;AACH,MAAM,WAAW,IAAI,CAAC,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC1B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC1B,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAChD;AAED;;;;GAIG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,MAAM,IAAI,IAAI,GAAG;IAAE,KAAK,EAAE,CAAC,CAAA;CAAE,CAAC;AAEnE;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;AAClD,MAAM,MAAM,cAAc,GAAG,cAAc,CAAC,UAAU,CAAC,CAAC;AACxD,MAAM,MAAM,WAAW,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/common/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAE1B;;GAEG;AACH,MAAM,WAAW,IAAI,CAAC,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC1B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC1B,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAChD;AAED;;;;GAIG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,MAAM,IAAI,IAAI,GAAG;IAAE,KAAK,EAAE,CAAC,CAAA;CAAE,CAAC;AAEnE;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC;AAClD,MAAM,MAAM,cAAc,GAAG,cAAc,CAAC,UAAU,CAAC,CAAC;AACxD,MAAM,MAAM,WAAW,GAAG,cAAc,CAAC,OAAO,CAAC,CAAC"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,QAAQ,CAAC;AACvB,cAAc,OAAO,CAAC;AACtB,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,QAAQ,CAAC;AACvB,cAAc,OAAO,CAAC;AACtB,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -22,5 +22,6 @@ __exportStar(require("./messaging"), exports);
|
|
|
22
22
|
__exportStar(require("./middleware"), exports);
|
|
23
23
|
__exportStar(require("./migrations"), exports);
|
|
24
24
|
__exportStar(require("./monitoring"), exports);
|
|
25
|
+
__exportStar(require("./replication"), exports);
|
|
25
26
|
__exportStar(require("./zapatos"), exports);
|
|
26
27
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,yCAAuB;AACvB,wCAAsB;AACtB,2CAAyB;AACzB,8CAA4B;AAC5B,8CAA4B;AAC5B,+CAA6B;AAC7B,+CAA6B;AAC7B,+CAA6B;AAC7B,4CAA0B"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,yCAAuB;AACvB,wCAAsB;AACtB,2CAAyB;AACzB,8CAA4B;AAC5B,8CAA4B;AAC5B,+CAA6B;AAC7B,+CAA6B;AAC7B,+CAA6B;AAC7B,gDAA8B;AAC9B,4CAA0B"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Pgoutput } from 'pg-logical-replication';
|
|
2
|
+
import { DbLogger } from '../common';
|
|
3
|
+
export type LogicalReplicationOperation = 'begin' | 'commit' | 'delete' | 'insert' | 'message' | 'origin' | 'relation' | 'truncate' | 'type' | 'update';
|
|
4
|
+
export interface PgOutputScopedMessage {
|
|
5
|
+
operation: LogicalReplicationOperation;
|
|
6
|
+
tableName?: string;
|
|
7
|
+
schemaName?: string;
|
|
8
|
+
new?: Record<string, any>;
|
|
9
|
+
old?: Record<string, any>;
|
|
10
|
+
}
|
|
11
|
+
export interface LogicalReplicatonMessageHandlerParams {
|
|
12
|
+
scopedMessage: PgOutputScopedMessage;
|
|
13
|
+
fullMessage: Pgoutput.Message;
|
|
14
|
+
}
|
|
15
|
+
export type LogicalReplicationMessageHandler = (params: LogicalReplicatonMessageHandlerParams) => Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Configuration object to set up a logical replication service.
|
|
18
|
+
*/
|
|
19
|
+
export interface LogicalReplicationServiceConfig {
|
|
20
|
+
connectionString: string;
|
|
21
|
+
/**
|
|
22
|
+
* Array of publication names for tables which changes would be watched.
|
|
23
|
+
* Must be defined beforehand on database level, e.g. using `ax_define.define_logical_replication_publication`
|
|
24
|
+
*/
|
|
25
|
+
publicationNames: string[];
|
|
26
|
+
/**
|
|
27
|
+
* Name of the replication slot which will be streaming table changes.
|
|
28
|
+
* Must be defined beforehand on database level, e.g. using `ax_define.define_logical_replication_slot`
|
|
29
|
+
*/
|
|
30
|
+
replicationSlotName: string;
|
|
31
|
+
/**
|
|
32
|
+
* Decides how to react to detected table changes.
|
|
33
|
+
*/
|
|
34
|
+
messageHandler: LogicalReplicationMessageHandler;
|
|
35
|
+
/**
|
|
36
|
+
* An array of operations that will be handled and redirected to the
|
|
37
|
+
* `messageHandler`. Other operations will be skipped, each producing a TRACE
|
|
38
|
+
* log.
|
|
39
|
+
*/
|
|
40
|
+
operationsToWatch?: LogicalReplicationOperation[];
|
|
41
|
+
/**
|
|
42
|
+
* Produces logs during the logical replication service runtime. If not
|
|
43
|
+
* passed, console logs are used instead.
|
|
44
|
+
*/
|
|
45
|
+
logger?: DbLogger;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Creates a logical replication service and keeps it running until stopped.
|
|
49
|
+
* Watches the table changes based on passed publication names and replication
|
|
50
|
+
* slot, handling messages based on the passed message handler. By default
|
|
51
|
+
* watches the "insert", "update", and "delete" operations.
|
|
52
|
+
*
|
|
53
|
+
* In case of errors, the service will try to recover for 20 attempts with an
|
|
54
|
+
* incremental delay between attempts for a total of ~5 minutes. Any success
|
|
55
|
+
* would reset the error attempts counter. Each failure will be logged with an
|
|
56
|
+
* attempts counter value. If 5 minutes passes without any errors - the errors
|
|
57
|
+
* counter is reset. If the counter increases over 20 in
|
|
58
|
+
* the span of 5 minutes - causing error will be thrown to crash the process
|
|
59
|
+
* to trigger a service reload that might resolve the issue.
|
|
60
|
+
*
|
|
61
|
+
* @param config Connection and processing config for the logical replication
|
|
62
|
+
* service.
|
|
63
|
+
* @returns a function to stop the logical replication service.
|
|
64
|
+
*/
|
|
65
|
+
export declare const createLogicalReplicationService: ({ connectionString, publicationNames, replicationSlotName, messageHandler, operationsToWatch, logger: passedLogger, }: LogicalReplicationServiceConfig) => Promise<{
|
|
66
|
+
(): Promise<void>;
|
|
67
|
+
}>;
|
|
68
|
+
//# sourceMappingURL=create-logical-replication-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-logical-replication-service.d.ts","sourceRoot":"","sources":["../../src/replication/create-logical-replication-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,QAAQ,EAET,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAKrC,MAAM,MAAM,2BAA2B,GACnC,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,QAAQ,GACR,SAAS,GACT,QAAQ,GACR,UAAU,GACV,UAAU,GACV,MAAM,GACN,QAAQ,CAAC;AAEb,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,2BAA2B,CAAC;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAE1B,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC3B;AAED,MAAM,WAAW,qCAAqC;IACpD,aAAa,EAAE,qBAAqB,CAAC;IACrC,WAAW,EAAE,QAAQ,CAAC,OAAO,CAAC;CAC/B;AAED,MAAM,MAAM,gCAAgC,GAAG,CAC7C,MAAM,EAAE,qCAAqC,KAC1C,OAAO,CAAC,IAAI,CAAC,CAAC;AAEnB;;GAEG;AACH,MAAM,WAAW,+BAA+B;IAI9C,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B;;;OAGG;IACH,mBAAmB,EAAE,MAAM,CAAC;IAE5B;;OAEG;IACH,cAAc,EAAE,gCAAgC,CAAC;IAEjD;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,2BAA2B,EAAE,CAAC;IAClD;;;OAGG;IACH,MAAM,CAAC,EAAE,QAAQ,CAAC;CACnB;AAmBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,+BAA+B,0HAOzC,+BAA+B,KAAG,QAAQ;IAAE,IAAI,QAAQ,IAAI,CAAC,CAAA;CAAE,CAmGjE,CAAC"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createLogicalReplicationService = void 0;
|
|
4
|
+
const pg_logical_replication_1 = require("pg-logical-replication");
|
|
5
|
+
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
6
|
+
const getScopedMessage = (message, operationsToWatch) => {
|
|
7
|
+
if (!operationsToWatch.includes(message.tag)) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
operation: message.tag,
|
|
12
|
+
tableName: 'relation' in message ? message.relation.name : undefined,
|
|
13
|
+
schemaName: 'relation' in message ? message.relation.schema : undefined,
|
|
14
|
+
new: 'new' in message ? message.new : undefined,
|
|
15
|
+
old: 'old' in message && message.old ? message.old : undefined,
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Creates a logical replication service and keeps it running until stopped.
|
|
20
|
+
* Watches the table changes based on passed publication names and replication
|
|
21
|
+
* slot, handling messages based on the passed message handler. By default
|
|
22
|
+
* watches the "insert", "update", and "delete" operations.
|
|
23
|
+
*
|
|
24
|
+
* In case of errors, the service will try to recover for 20 attempts with an
|
|
25
|
+
* incremental delay between attempts for a total of ~5 minutes. Any success
|
|
26
|
+
* would reset the error attempts counter. Each failure will be logged with an
|
|
27
|
+
* attempts counter value. If 5 minutes passes without any errors - the errors
|
|
28
|
+
* counter is reset. If the counter increases over 20 in
|
|
29
|
+
* the span of 5 minutes - causing error will be thrown to crash the process
|
|
30
|
+
* to trigger a service reload that might resolve the issue.
|
|
31
|
+
*
|
|
32
|
+
* @param config Connection and processing config for the logical replication
|
|
33
|
+
* service.
|
|
34
|
+
* @returns a function to stop the logical replication service.
|
|
35
|
+
*/
|
|
36
|
+
const createLogicalReplicationService = async ({ connectionString, publicationNames, replicationSlotName, messageHandler, operationsToWatch = ['insert', 'update', 'delete'], logger: passedLogger, }) => {
|
|
37
|
+
if (operationsToWatch.length === 0) {
|
|
38
|
+
throw new Error('Unable to start the logical replication service when operationsToWatch is an empty array.');
|
|
39
|
+
}
|
|
40
|
+
const logger = passedLogger !== null && passedLogger !== void 0 ? passedLogger : console;
|
|
41
|
+
const plugin = new pg_logical_replication_1.PgoutputPlugin({ protoVersion: 1, publicationNames });
|
|
42
|
+
let service;
|
|
43
|
+
let stopped = false;
|
|
44
|
+
let failedAttempts = 0;
|
|
45
|
+
let failedAttemptsResetTimer = undefined;
|
|
46
|
+
// Run the service in an endless background loop until it gets stopped
|
|
47
|
+
(async () => {
|
|
48
|
+
while (!stopped) {
|
|
49
|
+
try {
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
let heartbeatAckTimer = undefined;
|
|
52
|
+
service = new pg_logical_replication_1.LogicalReplicationService({ connectionString }, { acknowledge: { auto: false, timeoutSeconds: 0 } });
|
|
53
|
+
service.on('data', async (lsn, message) => {
|
|
54
|
+
try {
|
|
55
|
+
const scopedMessage = getScopedMessage(message, operationsToWatch);
|
|
56
|
+
if (scopedMessage) {
|
|
57
|
+
await messageHandler({ scopedMessage, fullMessage: message });
|
|
58
|
+
}
|
|
59
|
+
failedAttempts = 0;
|
|
60
|
+
clearTimeout(failedAttemptsResetTimer);
|
|
61
|
+
clearTimeout(heartbeatAckTimer);
|
|
62
|
+
await service.acknowledge(lsn);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
service.emit('error', error);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
service.on('error', async (err) => {
|
|
69
|
+
service.removeAllListeners();
|
|
70
|
+
await service.stop();
|
|
71
|
+
reject(err);
|
|
72
|
+
});
|
|
73
|
+
service.on('heartbeat', async (lsn, _timestamp, shouldRespond) => {
|
|
74
|
+
if (shouldRespond) {
|
|
75
|
+
heartbeatAckTimer = setTimeout(async () => {
|
|
76
|
+
logger.trace(`${lsn}: acknowledged heartbeat`);
|
|
77
|
+
await service.acknowledge(lsn);
|
|
78
|
+
}, 5000);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
service
|
|
82
|
+
.subscribe(plugin, replicationSlotName)
|
|
83
|
+
.then(() => resolve(true))
|
|
84
|
+
.catch(async (err) => {
|
|
85
|
+
service.removeAllListeners();
|
|
86
|
+
await service.stop();
|
|
87
|
+
reject(err);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
failedAttempts++;
|
|
93
|
+
if (failedAttempts > 20) {
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
logger.error(err, `Logical replication service failure has occurred. (Attempt ${failedAttempts})`);
|
|
97
|
+
await sleep(1500 * failedAttempts);
|
|
98
|
+
clearTimeout(failedAttemptsResetTimer);
|
|
99
|
+
failedAttemptsResetTimer = setTimeout(async () => {
|
|
100
|
+
logger.trace(`No errors have occurred within 5 minutes. Resetting failures counter after ${failedAttempts} failure(s).`);
|
|
101
|
+
failedAttempts = 0;
|
|
102
|
+
}, 300000);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
logger.debug(`Started the logical replication service to watch the database operations (${operationsToWatch.join(', ')}) on table(s) associated with the following publication(s): ${publicationNames.join(', ')}`);
|
|
107
|
+
return async () => {
|
|
108
|
+
logger.debug('Shutting down the logical replication service.');
|
|
109
|
+
stopped = true;
|
|
110
|
+
service === null || service === void 0 ? void 0 : service.removeAllListeners();
|
|
111
|
+
service === null || service === void 0 ? void 0 : service.stop().catch((e) => logger.error(e, 'Error on logical replicationservice shutdown.'));
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
exports.createLogicalReplicationService = createLogicalReplicationService;
|
|
115
|
+
//# sourceMappingURL=create-logical-replication-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-logical-replication-service.js","sourceRoot":"","sources":["../../src/replication/create-logical-replication-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/replication/index.ts"],"names":[],"mappings":"AAAA,cAAc,sCAAsC,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./create-logical-replication-service"), exports);
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/replication/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,uEAAqD"}
|
|
@@ -1104,3 +1104,61 @@ BEGIN
|
|
|
1104
1104
|
RETURN counter;
|
|
1105
1105
|
END;
|
|
1106
1106
|
$$;
|
|
1107
|
+
|
|
1108
|
+
/*-snippet
|
|
1109
|
+
{
|
|
1110
|
+
"body": [
|
|
1111
|
+
"BEGIN;",
|
|
1112
|
+
" SELECT ax_define.define_logical_replication_publication(",
|
|
1113
|
+
" '${1:publication_name_or_placeholder}',",
|
|
1114
|
+
" ARRAY['${2:table_name_one}', '${3:table_name_two}', '${4:table_name_three}'],",
|
|
1115
|
+
" '${5:app_public}');",
|
|
1116
|
+
"COMMIT;"
|
|
1117
|
+
],
|
|
1118
|
+
"description": [
|
|
1119
|
+
"Defines a publication for multiple tables.\n",
|
|
1120
|
+
"REPLICA IDENTITY FULL is set for each passed table.\n",
|
|
1121
|
+
"By default, sets the publish parameter of publication to `insert,update,delete`, but this can be overridden by the 4-th parameter.\n",
|
|
1122
|
+
"The result of a call is committed right away to enable adding a replication slot in context of the same migration."
|
|
1123
|
+
]
|
|
1124
|
+
}
|
|
1125
|
+
snippet-*/
|
|
1126
|
+
CREATE OR REPLACE FUNCTION ax_define.define_logical_replication_publication(
|
|
1127
|
+
publicationName text,
|
|
1128
|
+
tableNames text[],
|
|
1129
|
+
schemaName text,
|
|
1130
|
+
publicationOperations TEXT DEFAULT 'insert,update,delete'::text) RETURNS void AS $$
|
|
1131
|
+
DECLARE
|
|
1132
|
+
i integer;
|
|
1133
|
+
tableName text;
|
|
1134
|
+
BEGIN
|
|
1135
|
+
EXECUTE 'DROP PUBLICATION IF EXISTS ' || publicationName;
|
|
1136
|
+
EXECUTE 'CREATE PUBLICATION ' || publicationName || ' WITH (publish = "' || publicationOperations || '");';
|
|
1137
|
+
FOR i IN 1..array_length(tableNames, 1) LOOP
|
|
1138
|
+
tableName := tableNames[i];
|
|
1139
|
+
EXECUTE 'ALTER PUBLICATION ' || publicationName || ' ADD TABLE ' || schemaName || '.' || tableName || ';';
|
|
1140
|
+
EXECUTE 'ALTER TABLE ' || schemaName || '.' || tableName || ' REPLICA IDENTITY full;';
|
|
1141
|
+
END LOOP;
|
|
1142
|
+
END;
|
|
1143
|
+
$$ LANGUAGE plpgsql;
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
/*-snippet
|
|
1147
|
+
{
|
|
1148
|
+
"body": [
|
|
1149
|
+
"SELECT ax_define.define_logical_replication_slot('${1:replication_slot_name_or_placeholder}')"
|
|
1150
|
+
],
|
|
1151
|
+
"description": [
|
|
1152
|
+
"An idempotent wrapper for pg_create_logical_replication_slot.\n",
|
|
1153
|
+
"Plugin used by default is `pgoutput`. It can be overridden using the second parameter."
|
|
1154
|
+
]
|
|
1155
|
+
}
|
|
1156
|
+
snippet-*/
|
|
1157
|
+
CREATE OR REPLACE FUNCTION ax_define.define_logical_replication_slot(replicationSlotName text, pluginName TEXT DEFAULT 'pgoutput'::text) RETURNS void AS $$
|
|
1158
|
+
BEGIN
|
|
1159
|
+
IF NOT EXISTS(SELECT pg_drop_replication_slot(slot_name) from pg_replication_slots where slot_name = replicationSlotName)
|
|
1160
|
+
THEN
|
|
1161
|
+
PERFORM pg_create_logical_replication_slot(replicationSlotName, pluginName);
|
|
1162
|
+
END IF;
|
|
1163
|
+
END;
|
|
1164
|
+
$$ LANGUAGE plpgsql;
|
|
@@ -251,5 +251,23 @@
|
|
|
251
251
|
"Make sure the dropped value is not used in other tables.\n",
|
|
252
252
|
"This snippet has a simplified migration sample that might require adjustments."
|
|
253
253
|
]
|
|
254
|
+
},
|
|
255
|
+
"Define Logical Replication For Tables (Ax Custom)": {
|
|
256
|
+
"prefix": "ax-define-logical-replication-for-tables",
|
|
257
|
+
"body": [
|
|
258
|
+
"BEGIN;",
|
|
259
|
+
" SELECT ax_define.define_logical_replication_publication(",
|
|
260
|
+
" '${1:publication_name_or_placeholder}',",
|
|
261
|
+
" ARRAY['${2:table_name_one}', '${3:table_name_two}', '${4:table_name_three}'],",
|
|
262
|
+
" '${5:app_public}');",
|
|
263
|
+
"COMMIT;",
|
|
264
|
+
"SELECT ax_define.define_logical_replication_slot('${6:replication_slot_name_or_placeholder}')"
|
|
265
|
+
],
|
|
266
|
+
"description": [
|
|
267
|
+
"Defines a publication and a replication slot for multiple tables.\n",
|
|
268
|
+
"See snippets for individual define functions for more details.\n",
|
|
269
|
+
"The publications are committed right away to enable adding a replication slot in context of the same migration."
|
|
270
|
+
],
|
|
271
|
+
"scope": "sql"
|
|
254
272
|
}
|
|
255
273
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axinom/mosaic-db-common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0-rc.0",
|
|
4
4
|
"description": "This library encapsulates database-related functionality to develop Mosaic based services.",
|
|
5
5
|
"author": "Axinom",
|
|
6
6
|
"license": "PROPRIETARY",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"express": "^4.17.1",
|
|
34
34
|
"graphile-migrate": "^1.4.0",
|
|
35
35
|
"pg": "^8.5.1",
|
|
36
|
+
"pg-logical-replication": "^2.0.3",
|
|
36
37
|
"prom-client": "^13.2.0",
|
|
37
38
|
"readdirp": "^3.4.0",
|
|
38
39
|
"yargs": "^16.2.0",
|
|
@@ -51,5 +52,5 @@
|
|
|
51
52
|
"publishConfig": {
|
|
52
53
|
"access": "public"
|
|
53
54
|
},
|
|
54
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "d99f62328986c3250b0491a0b8b5f55d89b66840"
|
|
55
56
|
}
|
package/src/common/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { Pgoutput } from 'pg-logical-replication';
|
|
2
|
+
import {
|
|
3
|
+
LogicalReplicationMessageHandler,
|
|
4
|
+
createLogicalReplicationService,
|
|
5
|
+
} from './create-logical-replication-service';
|
|
6
|
+
|
|
7
|
+
const sleep = (ms: number): Promise<void> =>
|
|
8
|
+
new Promise((res) => setTimeout(res, ms));
|
|
9
|
+
|
|
10
|
+
const repService: {
|
|
11
|
+
// We expose the callback methods that are normally called from the "on"
|
|
12
|
+
// "data/error/heartbeat" emitted events to be able to manually call them.
|
|
13
|
+
handleData?: (lsn: string, log: any) => Promise<void> | void;
|
|
14
|
+
handleError?: (err: Error) => void;
|
|
15
|
+
handleHeartbeat?: (
|
|
16
|
+
lsn: string,
|
|
17
|
+
timestamp: number,
|
|
18
|
+
shouldRespond: boolean,
|
|
19
|
+
) => Promise<void> | void;
|
|
20
|
+
acknowledge?: jest.Mock<any, any>;
|
|
21
|
+
stop?: jest.Mock<any, any>;
|
|
22
|
+
} = {};
|
|
23
|
+
|
|
24
|
+
jest.mock('pg-logical-replication', () => {
|
|
25
|
+
return {
|
|
26
|
+
...jest.requireActual('pg-logical-replication'),
|
|
27
|
+
LogicalReplicationService: jest.fn().mockImplementation(() => {
|
|
28
|
+
const lrs = {
|
|
29
|
+
handleData: undefined,
|
|
30
|
+
handleError: undefined,
|
|
31
|
+
handleHeartbeat: undefined,
|
|
32
|
+
on: (event: 'data' | 'error' | 'heartbeat', listener: any) => {
|
|
33
|
+
switch (event) {
|
|
34
|
+
case 'data':
|
|
35
|
+
repService.handleData = listener;
|
|
36
|
+
break;
|
|
37
|
+
case 'error':
|
|
38
|
+
repService.handleError = listener;
|
|
39
|
+
break;
|
|
40
|
+
case 'heartbeat':
|
|
41
|
+
repService.handleHeartbeat = listener;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
acknowledge: jest.fn(),
|
|
46
|
+
removeAllListeners: jest.fn(),
|
|
47
|
+
emit: jest.fn(),
|
|
48
|
+
stop: jest.fn(() => Promise.resolve()),
|
|
49
|
+
subscribe: () =>
|
|
50
|
+
new Promise(() => {
|
|
51
|
+
/** never return */
|
|
52
|
+
}),
|
|
53
|
+
isStop: () => false,
|
|
54
|
+
};
|
|
55
|
+
repService.acknowledge = lrs.acknowledge;
|
|
56
|
+
repService.stop = lrs.stop;
|
|
57
|
+
return lrs;
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const relation: Pgoutput.MessageRelation = {
|
|
63
|
+
tag: 'relation',
|
|
64
|
+
relationOid: 1,
|
|
65
|
+
schema: 'test_schema',
|
|
66
|
+
name: 'test_table',
|
|
67
|
+
replicaIdentity: 'default',
|
|
68
|
+
columns: [
|
|
69
|
+
{
|
|
70
|
+
name: 'id',
|
|
71
|
+
flags: 0,
|
|
72
|
+
typeOid: 23,
|
|
73
|
+
typeMod: -1,
|
|
74
|
+
typeSchema: 'pg_catalog',
|
|
75
|
+
typeName: 'int4',
|
|
76
|
+
parser: (raw: any) => raw,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'name',
|
|
80
|
+
flags: 0,
|
|
81
|
+
typeOid: 25,
|
|
82
|
+
typeMod: -1,
|
|
83
|
+
typeSchema: 'pg_catalog',
|
|
84
|
+
typeName: 'text',
|
|
85
|
+
parser: (raw: any) => raw,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
keyColumns: ['id'],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
describe('createLogicalReplicationService', () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
repService.handleData = undefined;
|
|
94
|
+
repService.handleError = undefined;
|
|
95
|
+
repService.handleHeartbeat = undefined;
|
|
96
|
+
repService.acknowledge = undefined;
|
|
97
|
+
repService.stop = undefined;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('initialization throws an error if empty operations array is passed', async () => {
|
|
101
|
+
// Act
|
|
102
|
+
try {
|
|
103
|
+
await createLogicalReplicationService({
|
|
104
|
+
connectionString: 'test-valid-connection-string',
|
|
105
|
+
publicationNames: ['test_publication'],
|
|
106
|
+
replicationSlotName: 'test_slot',
|
|
107
|
+
messageHandler: jest.fn(),
|
|
108
|
+
operationsToWatch: [],
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Assert
|
|
112
|
+
// eslint-disable-next-line jest/no-conditional-expect
|
|
113
|
+
expect((error as Error).message).toBe(
|
|
114
|
+
'Unable to start the logical replication service when operationsToWatch is an empty array.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should call messageHandler and acknowledge the message when no errors are thrown', async () => {
|
|
120
|
+
// Arrange
|
|
121
|
+
const messageHandler: LogicalReplicationMessageHandler = jest.fn();
|
|
122
|
+
const cleanup = await createLogicalReplicationService({
|
|
123
|
+
connectionString: 'test-valid-connection-string',
|
|
124
|
+
publicationNames: ['test_publication'],
|
|
125
|
+
replicationSlotName: 'test_slot',
|
|
126
|
+
messageHandler,
|
|
127
|
+
});
|
|
128
|
+
expect(repService.handleData).toBeDefined();
|
|
129
|
+
const fullMessage = {
|
|
130
|
+
tag: 'insert',
|
|
131
|
+
relation,
|
|
132
|
+
old: {
|
|
133
|
+
id: 'test_id',
|
|
134
|
+
aggregate_type: 'test_type_old',
|
|
135
|
+
aggregate_id: 'test_aggregate_id_old',
|
|
136
|
+
event_type: 'test_event_type_old',
|
|
137
|
+
payload: { result: 'in_progress' },
|
|
138
|
+
created_at: new Date('2023-01-18T21:02:27.000Z'),
|
|
139
|
+
},
|
|
140
|
+
new: {
|
|
141
|
+
id: 'test_id',
|
|
142
|
+
aggregate_type: 'test_type',
|
|
143
|
+
aggregate_id: 'test_aggregate_id',
|
|
144
|
+
event_type: 'test_event_type',
|
|
145
|
+
payload: { result: 'success' },
|
|
146
|
+
created_at: new Date('2023-01-18T21:02:27.000Z'),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Act
|
|
151
|
+
await repService.handleData!('0/00000001', fullMessage);
|
|
152
|
+
|
|
153
|
+
// Assert
|
|
154
|
+
expect(repService.handleError).toBeDefined();
|
|
155
|
+
expect(repService.handleHeartbeat).toBeDefined();
|
|
156
|
+
expect(messageHandler).toHaveBeenCalledWith({
|
|
157
|
+
scopedMessage: {
|
|
158
|
+
new: fullMessage.new,
|
|
159
|
+
old: fullMessage.old,
|
|
160
|
+
operation: 'insert',
|
|
161
|
+
schemaName: 'test_schema',
|
|
162
|
+
tableName: 'test_table',
|
|
163
|
+
},
|
|
164
|
+
fullMessage,
|
|
165
|
+
});
|
|
166
|
+
expect(repService.acknowledge).toHaveBeenCalledWith('0/00000001');
|
|
167
|
+
expect(repService.stop).not.toHaveBeenCalled();
|
|
168
|
+
await cleanup();
|
|
169
|
+
expect(repService.stop).toHaveBeenCalledTimes(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should call messageHandler with minimal scoped message and acknowledge the message when no errors are thrown', async () => {
|
|
173
|
+
// Arrange
|
|
174
|
+
const messageHandler: LogicalReplicationMessageHandler = jest.fn();
|
|
175
|
+
const cleanup = await createLogicalReplicationService({
|
|
176
|
+
connectionString: 'test-valid-connection-string',
|
|
177
|
+
publicationNames: ['test_publication'],
|
|
178
|
+
replicationSlotName: 'test_slot',
|
|
179
|
+
messageHandler,
|
|
180
|
+
});
|
|
181
|
+
expect(repService.handleData).toBeDefined();
|
|
182
|
+
const fullMessage = {
|
|
183
|
+
tag: 'insert',
|
|
184
|
+
relation: {
|
|
185
|
+
tag: 'relation',
|
|
186
|
+
},
|
|
187
|
+
old: null,
|
|
188
|
+
new: undefined,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Act
|
|
192
|
+
await repService.handleData!('0/00000001', fullMessage);
|
|
193
|
+
|
|
194
|
+
// Assert
|
|
195
|
+
expect(repService.handleError).toBeDefined();
|
|
196
|
+
expect(repService.handleHeartbeat).toBeDefined();
|
|
197
|
+
expect(messageHandler).toHaveBeenCalledWith({
|
|
198
|
+
scopedMessage: {
|
|
199
|
+
new: undefined,
|
|
200
|
+
old: undefined,
|
|
201
|
+
operation: 'insert',
|
|
202
|
+
schemaName: undefined,
|
|
203
|
+
tableName: undefined,
|
|
204
|
+
},
|
|
205
|
+
fullMessage,
|
|
206
|
+
});
|
|
207
|
+
expect(repService.acknowledge).toHaveBeenCalledWith('0/00000001');
|
|
208
|
+
expect(repService.stop).not.toHaveBeenCalled();
|
|
209
|
+
await cleanup();
|
|
210
|
+
expect(repService.stop).toHaveBeenCalledTimes(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should call messageHandler but not acknowledge the message when an error is thrown', async () => {
|
|
214
|
+
// Arrange
|
|
215
|
+
const testError = new Error('Transient error');
|
|
216
|
+
const cleanup = await createLogicalReplicationService({
|
|
217
|
+
connectionString: 'test-valid-connection-string',
|
|
218
|
+
publicationNames: ['test_publication'],
|
|
219
|
+
replicationSlotName: 'test_slot',
|
|
220
|
+
messageHandler: async () => {
|
|
221
|
+
throw testError;
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(repService.handleData).toBeDefined();
|
|
226
|
+
|
|
227
|
+
// Act
|
|
228
|
+
await repService.handleData!('0/00000001', {
|
|
229
|
+
tag: 'insert',
|
|
230
|
+
relation,
|
|
231
|
+
new: {
|
|
232
|
+
id: 'test_id',
|
|
233
|
+
aggregate_type: 'test_type',
|
|
234
|
+
aggregate_id: 'test_aggregate_id',
|
|
235
|
+
event_type: 'test_event_type',
|
|
236
|
+
payload: { result: 'success' },
|
|
237
|
+
created_at: new Date('2023-01-18T21:02:27.000Z'),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Assert
|
|
242
|
+
expect(repService.handleError).toBeDefined();
|
|
243
|
+
expect(repService.handleHeartbeat).toBeDefined();
|
|
244
|
+
expect(repService.acknowledge).not.toHaveBeenCalled();
|
|
245
|
+
expect(repService.stop).not.toHaveBeenCalled();
|
|
246
|
+
await cleanup();
|
|
247
|
+
expect(repService.stop).toHaveBeenCalledTimes(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('A heartbeat should be acknowledged after 5 seconds', async () => {
|
|
251
|
+
// Arrange
|
|
252
|
+
const cleanup = await createLogicalReplicationService({
|
|
253
|
+
connectionString: 'test-valid-connection-string',
|
|
254
|
+
publicationNames: ['test_publication'],
|
|
255
|
+
replicationSlotName: 'test_slot',
|
|
256
|
+
messageHandler: jest.fn(),
|
|
257
|
+
});
|
|
258
|
+
expect(repService.handleHeartbeat).toBeDefined();
|
|
259
|
+
|
|
260
|
+
// Act
|
|
261
|
+
await repService.handleHeartbeat!('0/00000001', 123, true);
|
|
262
|
+
|
|
263
|
+
// Assert
|
|
264
|
+
expect(repService.handleData).toBeDefined();
|
|
265
|
+
expect(repService.handleError).toBeDefined();
|
|
266
|
+
expect(repService.acknowledge).not.toHaveBeenCalled();
|
|
267
|
+
await sleep(4000);
|
|
268
|
+
expect(repService.acknowledge).not.toHaveBeenCalled();
|
|
269
|
+
await sleep(1010);
|
|
270
|
+
expect(repService.acknowledge).toHaveBeenCalled();
|
|
271
|
+
expect(repService.stop).not.toHaveBeenCalled();
|
|
272
|
+
await cleanup();
|
|
273
|
+
expect(repService.stop).toHaveBeenCalledTimes(1);
|
|
274
|
+
}, 10_000);
|
|
275
|
+
|
|
276
|
+
it('A heartbeat should not be acknowledged after 5 seconds when a message acknowledgement comes in between', async () => {
|
|
277
|
+
// Arrange
|
|
278
|
+
const cleanup = await createLogicalReplicationService({
|
|
279
|
+
connectionString: 'test-valid-connection-string',
|
|
280
|
+
publicationNames: ['test_publication'],
|
|
281
|
+
replicationSlotName: 'test_slot',
|
|
282
|
+
messageHandler: jest.fn(),
|
|
283
|
+
});
|
|
284
|
+
expect(repService.handleData).toBeDefined();
|
|
285
|
+
expect(repService.handleHeartbeat).toBeDefined();
|
|
286
|
+
|
|
287
|
+
// Act
|
|
288
|
+
await repService.handleHeartbeat!('0/00000001', 123, true);
|
|
289
|
+
await sleep(1000);
|
|
290
|
+
await repService.handleData!('0/00000002', {
|
|
291
|
+
tag: 'insert',
|
|
292
|
+
relation,
|
|
293
|
+
new: {
|
|
294
|
+
id: 'test_id',
|
|
295
|
+
aggregate_type: 'test_type',
|
|
296
|
+
aggregate_id: 'test_aggregate_id',
|
|
297
|
+
event_type: 'test_event_type',
|
|
298
|
+
payload: { result: 'success' },
|
|
299
|
+
created_at: new Date('2023-01-18T21:02:27.000Z'),
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Assert
|
|
304
|
+
expect(repService.handleError).toBeDefined();
|
|
305
|
+
expect(repService.acknowledge).toHaveBeenCalledWith('0/00000002');
|
|
306
|
+
await sleep(4010);
|
|
307
|
+
expect(repService.acknowledge).toHaveBeenCalledTimes(1);
|
|
308
|
+
expect(repService.stop).not.toHaveBeenCalled();
|
|
309
|
+
await cleanup();
|
|
310
|
+
expect(repService.stop).toHaveBeenCalledTimes(1);
|
|
311
|
+
}, 10_000);
|
|
312
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LogicalReplicationService,
|
|
3
|
+
Pgoutput,
|
|
4
|
+
PgoutputPlugin,
|
|
5
|
+
} from 'pg-logical-replication';
|
|
6
|
+
import { DbLogger } from '../common';
|
|
7
|
+
|
|
8
|
+
const sleep = (ms: number): Promise<void> =>
|
|
9
|
+
new Promise((res) => setTimeout(res, ms));
|
|
10
|
+
|
|
11
|
+
export type LogicalReplicationOperation =
|
|
12
|
+
| 'begin'
|
|
13
|
+
| 'commit'
|
|
14
|
+
| 'delete'
|
|
15
|
+
| 'insert'
|
|
16
|
+
| 'message'
|
|
17
|
+
| 'origin'
|
|
18
|
+
| 'relation'
|
|
19
|
+
| 'truncate'
|
|
20
|
+
| 'type'
|
|
21
|
+
| 'update';
|
|
22
|
+
|
|
23
|
+
export interface PgOutputScopedMessage {
|
|
24
|
+
operation: LogicalReplicationOperation;
|
|
25
|
+
tableName?: string;
|
|
26
|
+
schemaName?: string;
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
new?: Record<string, any>;
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
old?: Record<string, any>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LogicalReplicatonMessageHandlerParams {
|
|
34
|
+
scopedMessage: PgOutputScopedMessage;
|
|
35
|
+
fullMessage: Pgoutput.Message;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type LogicalReplicationMessageHandler = (
|
|
39
|
+
params: LogicalReplicatonMessageHandlerParams,
|
|
40
|
+
) => Promise<void>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Configuration object to set up a logical replication service.
|
|
44
|
+
*/
|
|
45
|
+
export interface LogicalReplicationServiceConfig {
|
|
46
|
+
/*
|
|
47
|
+
Database connection string for the user with `REPLICATION` role
|
|
48
|
+
*/
|
|
49
|
+
connectionString: string;
|
|
50
|
+
/**
|
|
51
|
+
* Array of publication names for tables which changes would be watched.
|
|
52
|
+
* Must be defined beforehand on database level, e.g. using `ax_define.define_logical_replication_publication`
|
|
53
|
+
*/
|
|
54
|
+
publicationNames: string[];
|
|
55
|
+
/**
|
|
56
|
+
* Name of the replication slot which will be streaming table changes.
|
|
57
|
+
* Must be defined beforehand on database level, e.g. using `ax_define.define_logical_replication_slot`
|
|
58
|
+
*/
|
|
59
|
+
replicationSlotName: string;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decides how to react to detected table changes.
|
|
63
|
+
*/
|
|
64
|
+
messageHandler: LogicalReplicationMessageHandler;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An array of operations that will be handled and redirected to the
|
|
68
|
+
* `messageHandler`. Other operations will be skipped, each producing a TRACE
|
|
69
|
+
* log.
|
|
70
|
+
*/
|
|
71
|
+
operationsToWatch?: LogicalReplicationOperation[];
|
|
72
|
+
/**
|
|
73
|
+
* Produces logs during the logical replication service runtime. If not
|
|
74
|
+
* passed, console logs are used instead.
|
|
75
|
+
*/
|
|
76
|
+
logger?: DbLogger;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const getScopedMessage = (
|
|
80
|
+
message: Pgoutput.Message,
|
|
81
|
+
operationsToWatch: string[],
|
|
82
|
+
): PgOutputScopedMessage | undefined => {
|
|
83
|
+
if (!operationsToWatch.includes(message.tag)) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
operation: message.tag,
|
|
89
|
+
tableName: 'relation' in message ? message.relation.name : undefined,
|
|
90
|
+
schemaName: 'relation' in message ? message.relation.schema : undefined,
|
|
91
|
+
new: 'new' in message ? message.new : undefined,
|
|
92
|
+
old: 'old' in message && message.old ? message.old : undefined,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Creates a logical replication service and keeps it running until stopped.
|
|
98
|
+
* Watches the table changes based on passed publication names and replication
|
|
99
|
+
* slot, handling messages based on the passed message handler. By default
|
|
100
|
+
* watches the "insert", "update", and "delete" operations.
|
|
101
|
+
*
|
|
102
|
+
* In case of errors, the service will try to recover for 20 attempts with an
|
|
103
|
+
* incremental delay between attempts for a total of ~5 minutes. Any success
|
|
104
|
+
* would reset the error attempts counter. Each failure will be logged with an
|
|
105
|
+
* attempts counter value. If 5 minutes passes without any errors - the errors
|
|
106
|
+
* counter is reset. If the counter increases over 20 in
|
|
107
|
+
* the span of 5 minutes - causing error will be thrown to crash the process
|
|
108
|
+
* to trigger a service reload that might resolve the issue.
|
|
109
|
+
*
|
|
110
|
+
* @param config Connection and processing config for the logical replication
|
|
111
|
+
* service.
|
|
112
|
+
* @returns a function to stop the logical replication service.
|
|
113
|
+
*/
|
|
114
|
+
export const createLogicalReplicationService = async ({
|
|
115
|
+
connectionString,
|
|
116
|
+
publicationNames,
|
|
117
|
+
replicationSlotName,
|
|
118
|
+
messageHandler,
|
|
119
|
+
operationsToWatch = ['insert', 'update', 'delete'],
|
|
120
|
+
logger: passedLogger,
|
|
121
|
+
}: LogicalReplicationServiceConfig): Promise<{ (): Promise<void> }> => {
|
|
122
|
+
if (operationsToWatch.length === 0) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'Unable to start the logical replication service when operationsToWatch is an empty array.',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const logger = passedLogger ?? console;
|
|
129
|
+
const plugin = new PgoutputPlugin({ protoVersion: 1, publicationNames });
|
|
130
|
+
let service: LogicalReplicationService;
|
|
131
|
+
let stopped = false;
|
|
132
|
+
let failedAttempts = 0;
|
|
133
|
+
let failedAttemptsResetTimer: NodeJS.Timeout | undefined = undefined;
|
|
134
|
+
// Run the service in an endless background loop until it gets stopped
|
|
135
|
+
(async () => {
|
|
136
|
+
while (!stopped) {
|
|
137
|
+
try {
|
|
138
|
+
await new Promise((resolve, reject) => {
|
|
139
|
+
let heartbeatAckTimer: NodeJS.Timeout | undefined = undefined;
|
|
140
|
+
service = new LogicalReplicationService(
|
|
141
|
+
{ connectionString },
|
|
142
|
+
{ acknowledge: { auto: false, timeoutSeconds: 0 } },
|
|
143
|
+
);
|
|
144
|
+
service.on('data', async (lsn: string, message: Pgoutput.Message) => {
|
|
145
|
+
try {
|
|
146
|
+
const scopedMessage = getScopedMessage(
|
|
147
|
+
message,
|
|
148
|
+
operationsToWatch,
|
|
149
|
+
);
|
|
150
|
+
if (scopedMessage) {
|
|
151
|
+
await messageHandler({ scopedMessage, fullMessage: message });
|
|
152
|
+
}
|
|
153
|
+
failedAttempts = 0;
|
|
154
|
+
clearTimeout(failedAttemptsResetTimer);
|
|
155
|
+
clearTimeout(heartbeatAckTimer);
|
|
156
|
+
await service.acknowledge(lsn);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
service.emit('error', error);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
service.on('error', async (err: Error) => {
|
|
162
|
+
service.removeAllListeners();
|
|
163
|
+
await service.stop();
|
|
164
|
+
reject(err);
|
|
165
|
+
});
|
|
166
|
+
service.on('heartbeat', async (lsn, _timestamp, shouldRespond) => {
|
|
167
|
+
if (shouldRespond) {
|
|
168
|
+
heartbeatAckTimer = setTimeout(async () => {
|
|
169
|
+
logger.trace(`${lsn}: acknowledged heartbeat`);
|
|
170
|
+
await service.acknowledge(lsn);
|
|
171
|
+
}, 5000);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
service
|
|
175
|
+
.subscribe(plugin, replicationSlotName)
|
|
176
|
+
.then(() => resolve(true))
|
|
177
|
+
.catch(async (err) => {
|
|
178
|
+
service.removeAllListeners();
|
|
179
|
+
await service.stop();
|
|
180
|
+
reject(err);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
failedAttempts++;
|
|
185
|
+
if (failedAttempts > 20) {
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
logger.error(
|
|
189
|
+
err as Error,
|
|
190
|
+
`Logical replication service failure has occurred. (Attempt ${failedAttempts})`,
|
|
191
|
+
);
|
|
192
|
+
await sleep(1500 * failedAttempts);
|
|
193
|
+
clearTimeout(failedAttemptsResetTimer);
|
|
194
|
+
failedAttemptsResetTimer = setTimeout(async () => {
|
|
195
|
+
logger.trace(
|
|
196
|
+
`No errors have occurred within 5 minutes. Resetting failures counter after ${failedAttempts} failure(s).`,
|
|
197
|
+
);
|
|
198
|
+
failedAttempts = 0;
|
|
199
|
+
}, 300000);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
})();
|
|
203
|
+
logger.debug(
|
|
204
|
+
`Started the logical replication service to watch the database operations (${operationsToWatch.join(
|
|
205
|
+
', ',
|
|
206
|
+
)}) on table(s) associated with the following publication(s): ${publicationNames.join(
|
|
207
|
+
', ',
|
|
208
|
+
)}`,
|
|
209
|
+
);
|
|
210
|
+
return async () => {
|
|
211
|
+
logger.debug('Shutting down the logical replication service.');
|
|
212
|
+
stopped = true;
|
|
213
|
+
service?.removeAllListeners();
|
|
214
|
+
service
|
|
215
|
+
?.stop()
|
|
216
|
+
.catch((e) =>
|
|
217
|
+
logger.error(e, 'Error on logical replicationservice shutdown.'),
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './create-logical-replication-service';
|