@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.
@@ -14,6 +14,7 @@ export interface Dict<T> {
14
14
  * ```
15
15
  */
16
16
  export interface DbLogger {
17
+ trace(message: string): void;
17
18
  debug(message: string): void;
18
19
  debug(error: Error): void;
19
20
  error(message: string): void;
@@ -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
@@ -6,5 +6,6 @@ export * from './messaging';
6
6
  export * from './middleware';
7
7
  export * from './migrations';
8
8
  export * from './monitoring';
9
+ export * from './replication';
9
10
  export * from './zapatos';
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -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,2 @@
1
+ export * from './create-logical-replication-service';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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.24.3",
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": "bfef034fdb70100b54e35c441f75301d2b4585fd"
55
+ "gitHead": "d99f62328986c3250b0491a0b8b5f55d89b66840"
55
56
  }
@@ -16,6 +16,7 @@ export interface Dict<T> {
16
16
  * ```
17
17
  */
18
18
  export interface DbLogger {
19
+ trace(message: string): void;
19
20
  debug(message: string): void;
20
21
  debug(error: Error): void;
21
22
  error(message: string): void;
package/src/index.ts CHANGED
@@ -6,4 +6,5 @@ export * from './messaging';
6
6
  export * from './middleware';
7
7
  export * from './migrations';
8
8
  export * from './monitoring';
9
+ export * from './replication';
9
10
  export * from './zapatos';
@@ -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';