@orion-js/echoes 3.10.0 → 3.11.1
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/lib/index.d.ts +2 -2
- package/lib/index.js +20 -3
- package/lib/publish/index.js +4 -4
- package/lib/startService/KafkaManager.d.ts +24 -0
- package/lib/startService/KafkaManager.js +146 -0
- package/lib/startService/index.d.ts +1 -0
- package/lib/startService/index.js +15 -26
- package/lib/types.d.ts +10 -1
- package/package.json +5 -6
package/lib/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import startService from './startService';
|
|
1
|
+
import startService, { stopService } from './startService';
|
|
2
2
|
import publish from './publish';
|
|
3
3
|
import echo from './echo';
|
|
4
4
|
import request from './request';
|
|
5
5
|
export * from './types';
|
|
6
6
|
export * from './service';
|
|
7
|
-
export { publish, startService, echo, request };
|
|
7
|
+
export { publish, startService, stopService, echo, request };
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
3
|
if (k2 === undefined) k2 = k;
|
|
4
|
-
Object.
|
|
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);
|
|
5
9
|
}) : (function(o, m, k, k2) {
|
|
6
10
|
if (k2 === undefined) k2 = k;
|
|
7
11
|
o[k2] = m[k];
|
|
8
12
|
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
9
25
|
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
10
26
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
11
27
|
};
|
|
@@ -13,9 +29,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
13
29
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
30
|
};
|
|
15
31
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
-
exports.request = exports.echo = exports.startService = exports.publish = void 0;
|
|
17
|
-
const startService_1 =
|
|
32
|
+
exports.request = exports.echo = exports.stopService = exports.startService = exports.publish = void 0;
|
|
33
|
+
const startService_1 = __importStar(require("./startService"));
|
|
18
34
|
exports.startService = startService_1.default;
|
|
35
|
+
Object.defineProperty(exports, "stopService", { enumerable: true, get: function () { return startService_1.stopService; } });
|
|
19
36
|
const publish_1 = __importDefault(require("./publish"));
|
|
20
37
|
exports.publish = publish_1.default;
|
|
21
38
|
const echo_1 = __importDefault(require("./echo"));
|
package/lib/publish/index.js
CHANGED
|
@@ -13,7 +13,7 @@ async function publish(options) {
|
|
|
13
13
|
throw new Error('You must initialize echoes configruation to use publish');
|
|
14
14
|
}
|
|
15
15
|
const payload = {
|
|
16
|
-
params: options.params
|
|
16
|
+
params: options.params,
|
|
17
17
|
};
|
|
18
18
|
return await config_1.default.producer.send({
|
|
19
19
|
acks: options.acks,
|
|
@@ -21,9 +21,9 @@ async function publish(options) {
|
|
|
21
21
|
topic: options.topic,
|
|
22
22
|
messages: [
|
|
23
23
|
{
|
|
24
|
-
value: (0, serialize_1.default)(payload)
|
|
25
|
-
}
|
|
26
|
-
]
|
|
24
|
+
value: (0, serialize_1.default)(payload),
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
exports.default = publish;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { Kafka, EachMessagePayload, Producer, Consumer } from 'kafkajs';
|
|
3
|
+
import { EchoesOptions, EchoType } from '../types';
|
|
4
|
+
/**
|
|
5
|
+
* Manages the Kafka connection and the consumers.
|
|
6
|
+
*/
|
|
7
|
+
declare class KafkaManager {
|
|
8
|
+
kafka: Kafka;
|
|
9
|
+
options: EchoesOptions;
|
|
10
|
+
producer: Producer;
|
|
11
|
+
consumer: Consumer;
|
|
12
|
+
topics: string[];
|
|
13
|
+
started: boolean;
|
|
14
|
+
interval: NodeJS.Timeout;
|
|
15
|
+
constructor(options: EchoesOptions);
|
|
16
|
+
checkJoinConsumerGroupConditions(): Promise<boolean>;
|
|
17
|
+
joinConsumerGroup(): Promise<void>;
|
|
18
|
+
conditionalStart(): Promise<boolean>;
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
stop(): Promise<void>;
|
|
21
|
+
handleMessage(params: EachMessagePayload): Promise<void>;
|
|
22
|
+
handleRetries(echo: EchoType, params: EachMessagePayload, error: Error): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export default KafkaManager;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const kafkajs_1 = require("kafkajs");
|
|
7
|
+
const types_1 = __importDefault(require("../echo/types"));
|
|
8
|
+
const HEARTBEAT_INTERVAL_SECONDS = 5; // This value must be less than the kafkajs session timeout
|
|
9
|
+
const CHECK_JOIN_CONSUMER_INTERVAL_SECONDS = 30;
|
|
10
|
+
const DEFAULT_PARTITIONS_CONSUMED_CONCURRENTLY = 4; // How many partitions to consume concurrently, adjust this with the members to partitions ratio to avoid idle consumers.
|
|
11
|
+
const DEFAULT_MEMBERS_TO_PARTITIONS_RATIO = 1; // How many members are in comparison to partitions, this is used to determine if the consumer group has room for more members. Numbers over 1 leads to idle consumers. Numbers under 1 needs partitionsConsumedConcurrently to be more than 1.
|
|
12
|
+
/**
|
|
13
|
+
* Manages the Kafka connection and the consumers.
|
|
14
|
+
*/
|
|
15
|
+
class KafkaManager {
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.kafka = new kafkajs_1.Kafka(options.client);
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.producer = this.kafka.producer(options.producer);
|
|
20
|
+
this.consumer = this.kafka.consumer(options.consumer);
|
|
21
|
+
this.topics = Object.keys(options.echoes).filter(key => options.echoes[key].type === types_1.default.event);
|
|
22
|
+
}
|
|
23
|
+
async checkJoinConsumerGroupConditions() {
|
|
24
|
+
const admin = this.kafka.admin();
|
|
25
|
+
try {
|
|
26
|
+
await admin.connect();
|
|
27
|
+
const groupDescriptions = await admin.describeGroups([this.options.consumer.groupId]);
|
|
28
|
+
const group = groupDescriptions.groups[0];
|
|
29
|
+
if (group.state === 'Empty') {
|
|
30
|
+
console.info(`Echoes: Consumer group ${this.options.consumer.groupId} is empty, joining`);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
const topicsMetadata = await admin.fetchTopicMetadata({ topics: this.topics });
|
|
34
|
+
const totalPartitions = topicsMetadata.topics.reduce((acc, topic) => acc + topic.partitions.length, 0);
|
|
35
|
+
console.info(`Echoes: Consumer group ${this.options.consumer.groupId} has ${group.members.length} members and ${totalPartitions} partitions`);
|
|
36
|
+
const partitionsRatio = this.options.membersToPartitionsRatio || DEFAULT_MEMBERS_TO_PARTITIONS_RATIO;
|
|
37
|
+
const partitionsThreshold = Math.ceil(totalPartitions * partitionsRatio);
|
|
38
|
+
if (partitionsThreshold > group.members.length) {
|
|
39
|
+
console.info(`Echoes: Consumer group ${this.options.consumer.groupId} has room for more members ${group.members.length}/${partitionsThreshold}, joining`);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error(`Echoes: Error checking consumer group conditions, join: ${error.message}`);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await admin.disconnect().catch(error => {
|
|
49
|
+
console.error(`Echoes: Error disconnecting admin client: ${error.message}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async joinConsumerGroup() {
|
|
54
|
+
await this.consumer.connect();
|
|
55
|
+
await this.consumer.subscribe({ topics: this.topics });
|
|
56
|
+
await this.consumer.run({
|
|
57
|
+
partitionsConsumedConcurrently: this.options.partitionsConsumedConcurrently || DEFAULT_PARTITIONS_CONSUMED_CONCURRENTLY,
|
|
58
|
+
eachMessage: params => this.handleMessage(params),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async conditionalStart() {
|
|
62
|
+
if (await this.checkJoinConsumerGroupConditions()) {
|
|
63
|
+
await this.joinConsumerGroup();
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async start() {
|
|
68
|
+
if (this.started)
|
|
69
|
+
return;
|
|
70
|
+
await this.producer.connect();
|
|
71
|
+
this.started = await this.conditionalStart();
|
|
72
|
+
if (this.started)
|
|
73
|
+
return;
|
|
74
|
+
console.info('Echoes: Delaying consumer group join, waiting for conditions to be met');
|
|
75
|
+
this.interval = setInterval(async () => {
|
|
76
|
+
this.started = await this.conditionalStart();
|
|
77
|
+
if (this.started)
|
|
78
|
+
clearInterval(this.interval);
|
|
79
|
+
}, CHECK_JOIN_CONSUMER_INTERVAL_SECONDS * 1000);
|
|
80
|
+
}
|
|
81
|
+
async stop() {
|
|
82
|
+
console.warn('Echoes: Stopping echoes');
|
|
83
|
+
if (this.interval)
|
|
84
|
+
clearInterval(this.interval);
|
|
85
|
+
if (this.consumer)
|
|
86
|
+
await this.consumer.disconnect();
|
|
87
|
+
if (this.producer)
|
|
88
|
+
await this.producer.disconnect();
|
|
89
|
+
}
|
|
90
|
+
async handleMessage(params) {
|
|
91
|
+
const echo = this.options.echoes[params.topic];
|
|
92
|
+
if (!echo || echo.type !== types_1.default.event) {
|
|
93
|
+
console.warn(`Echoes: Received a message for an unknown topic: ${params.topic}, ignoring it`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let intervalsCount = 0;
|
|
97
|
+
const heartbeatInterval = setInterval(async () => {
|
|
98
|
+
await params.heartbeat().catch(error => {
|
|
99
|
+
console.warn(`Echoes: Error sending heartbeat: ${error.message}`);
|
|
100
|
+
});
|
|
101
|
+
intervalsCount++;
|
|
102
|
+
if ((intervalsCount * HEARTBEAT_INTERVAL_SECONDS) % 30 === 0) {
|
|
103
|
+
console.warn(`Echoes: Event is taking too long to process: ${params.topic} ${intervalsCount * HEARTBEAT_INTERVAL_SECONDS}s`);
|
|
104
|
+
}
|
|
105
|
+
}, HEARTBEAT_INTERVAL_SECONDS * 1000);
|
|
106
|
+
try {
|
|
107
|
+
await echo.onMessage(params).catch(error => this.handleRetries(echo, params, error));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
console.error(`Echoes: error processing a message: ${params.topic} ${error.message}`);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
clearInterval(heartbeatInterval);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async handleRetries(echo, params, error) {
|
|
118
|
+
const { message, topic } = params;
|
|
119
|
+
const retries = Number.parseInt(message?.headers?.retries?.toString() || '0', 10);
|
|
120
|
+
if (echo.attemptsBeforeDeadLetter === undefined || echo.attemptsBeforeDeadLetter === null) {
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
const maxRetries = echo.attemptsBeforeDeadLetter || 0;
|
|
124
|
+
const exceededMaxRetries = retries >= maxRetries;
|
|
125
|
+
const nextTopic = exceededMaxRetries ? `DLQ-${topic}` : topic;
|
|
126
|
+
await this.producer.send({
|
|
127
|
+
topic: nextTopic,
|
|
128
|
+
messages: [
|
|
129
|
+
{
|
|
130
|
+
value: message.value.toString(),
|
|
131
|
+
headers: {
|
|
132
|
+
retries: String(retries + 1),
|
|
133
|
+
error: error.message,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
if (exceededMaxRetries) {
|
|
139
|
+
console.error(`Echoes: a message has reached the maximum number of retries, sending it to DLQ: ${nextTopic}`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
console.warn(`Echoes: a retryable message failed "${error.message}", re-sending it to topic: ${nextTopic}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
exports.default = KafkaManager;
|
|
@@ -3,11 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
|
|
6
|
+
exports.stopService = void 0;
|
|
7
7
|
const config_1 = __importDefault(require("../config"));
|
|
8
8
|
const requestsHandler_1 = __importDefault(require("../requestsHandler"));
|
|
9
|
-
const
|
|
9
|
+
const KafkaManager_1 = __importDefault(require("./KafkaManager"));
|
|
10
10
|
const http_1 = require("@orion-js/http");
|
|
11
|
+
let kafkaManager = null;
|
|
11
12
|
async function startService(options) {
|
|
12
13
|
config_1.default.echoes = options.echoes;
|
|
13
14
|
if (options.requests) {
|
|
@@ -15,30 +16,18 @@ async function startService(options) {
|
|
|
15
16
|
(0, http_1.registerRoute)((0, requestsHandler_1.default)(options));
|
|
16
17
|
}
|
|
17
18
|
if (options.client) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
config_1.default.
|
|
21
|
-
|
|
22
|
-
await config_1.default.consumer.connect();
|
|
23
|
-
for (const topic in options.echoes) {
|
|
24
|
-
const echo = options.echoes[topic];
|
|
25
|
-
if (echo.type !== types_1.default.event)
|
|
26
|
-
continue;
|
|
27
|
-
await config_1.default.consumer.subscribe({
|
|
28
|
-
topic,
|
|
29
|
-
fromBeginning: options.readTopicsFromBeginning === false ? false : true
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
config_1.default.consumer.run({
|
|
33
|
-
eachMessage: async (payload) => {
|
|
34
|
-
const echo = options.echoes[payload.topic];
|
|
35
|
-
if (!echo)
|
|
36
|
-
return;
|
|
37
|
-
if (echo.type !== types_1.default.event)
|
|
38
|
-
return;
|
|
39
|
-
await echo.onMessage(payload);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
19
|
+
kafkaManager = new KafkaManager_1.default(options);
|
|
20
|
+
await kafkaManager.start();
|
|
21
|
+
config_1.default.producer = kafkaManager.producer;
|
|
22
|
+
config_1.default.consumer = kafkaManager.consumer;
|
|
42
23
|
}
|
|
43
24
|
}
|
|
44
25
|
exports.default = startService;
|
|
26
|
+
async function stopService() {
|
|
27
|
+
if (kafkaManager) {
|
|
28
|
+
console.info('Stoping echoes...');
|
|
29
|
+
await kafkaManager.stop();
|
|
30
|
+
console.info('Echoes stopped');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.stopService = stopService;
|
package/lib/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ConsumerConfig, KafkaConfig, ProducerConfig, Consumer, Producer, EachMe
|
|
|
2
2
|
export interface EchoConfig {
|
|
3
3
|
type: 'event' | 'request';
|
|
4
4
|
resolve(params: any, context?: any): Promise<any>;
|
|
5
|
+
attemptsBeforeDeadLetter?: number;
|
|
5
6
|
}
|
|
6
7
|
export interface EchoType extends EchoConfig {
|
|
7
8
|
onMessage(messageData: EachMessagePayload): Promise<void>;
|
|
@@ -45,7 +46,7 @@ export interface RequestMakerResult {
|
|
|
45
46
|
statusCode: number;
|
|
46
47
|
data: object;
|
|
47
48
|
}
|
|
48
|
-
export
|
|
49
|
+
export type RequestMaker = (options: MakeRequestParams) => Promise<RequestMakerResult>;
|
|
49
50
|
export interface RequestsConfig {
|
|
50
51
|
/**
|
|
51
52
|
* The secret key used to sign all requests. Shared between all your services.
|
|
@@ -80,6 +81,14 @@ export interface EchoesOptions {
|
|
|
80
81
|
* Defaults to true. When true, allows a reconnecting service to read missed messages.
|
|
81
82
|
*/
|
|
82
83
|
readTopicsFromBeginning?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Defaults to 4. How many partitions to consume concurrently, adjust this with the members to partitions ratio to avoid idle consumers.
|
|
86
|
+
*/
|
|
87
|
+
partitionsConsumedConcurrently?: number;
|
|
88
|
+
/**
|
|
89
|
+
* Defaults to 1. How many members are in comparison to partitions, this is used to determine if the consumer group has room for more members. Numbers over 1 leads to idle consumers. Numbers under 1 needs partitionsConsumedConcurrently to be more than 1.
|
|
90
|
+
*/
|
|
91
|
+
membersToPartitionsRatio?: number;
|
|
83
92
|
}
|
|
84
93
|
export interface EchoesConfigHandler {
|
|
85
94
|
producer?: Producer;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orion-js/echoes",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.1",
|
|
4
4
|
"main": "lib/index.js",
|
|
5
5
|
"types": "lib/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -19,17 +19,16 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@orion-js/env": "^3.10.0",
|
|
21
21
|
"@orion-js/helpers": "^3.10.0",
|
|
22
|
-
"@orion-js/http": "^3.
|
|
22
|
+
"@orion-js/http": "^3.11.1",
|
|
23
23
|
"@orion-js/schema": "^3.10.0",
|
|
24
|
-
"@orion-js/services": "^3.
|
|
24
|
+
"@orion-js/services": "^3.11.1",
|
|
25
25
|
"axios": "^0.24.0",
|
|
26
26
|
"jssha": "^3.2.0",
|
|
27
|
-
"kafkajs": "^
|
|
27
|
+
"kafkajs": "^2.2.4",
|
|
28
28
|
"lodash": "^4.17.21",
|
|
29
29
|
"serialize-javascript": "^6.0.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@shelf/jest-mongodb": "^2.1.0",
|
|
33
32
|
"@types/jest": "27.0.2",
|
|
34
33
|
"@types/supertest": "2.0.11",
|
|
35
34
|
"jest": "27.3.1",
|
|
@@ -41,5 +40,5 @@
|
|
|
41
40
|
"publishConfig": {
|
|
42
41
|
"access": "public"
|
|
43
42
|
},
|
|
44
|
-
"gitHead": "
|
|
43
|
+
"gitHead": "6d60b64d2b61ff2a74884cf4eb4eea806c2231c2"
|
|
45
44
|
}
|