@hahnpro/flow-sdk 9.6.4 → 2025.2.0-beta.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/CHANGELOG.md +904 -0
- package/jest.config.ts +10 -0
- package/package.json +7 -20
- package/project.json +41 -0
- package/src/index.ts +15 -0
- package/src/lib/ContextManager.ts +111 -0
- package/src/lib/FlowApplication.ts +659 -0
- package/src/lib/FlowElement.ts +220 -0
- package/src/lib/FlowEvent.ts +73 -0
- package/src/lib/FlowLogger.ts +131 -0
- package/src/lib/FlowModule.ts +18 -0
- package/src/lib/RpcClient.ts +99 -0
- package/src/lib/TestModule.ts +14 -0
- package/src/lib/__pycache__/rpc_server.cpython-310.pyc +0 -0
- package/src/lib/amqp.ts +32 -0
- package/src/lib/extra-validators.ts +62 -0
- package/src/lib/flow.interface.ts +56 -0
- package/{dist/index.d.ts → src/lib/index.ts} +3 -0
- package/src/lib/nats.ts +140 -0
- package/src/lib/unit-decorators.ts +156 -0
- package/src/lib/unit-utils.ts +163 -0
- package/src/lib/units.ts +587 -0
- package/src/lib/utils.ts +176 -0
- package/test/context-manager-purpose.spec.ts +248 -0
- package/test/context-manager.spec.ts +55 -0
- package/test/context.spec.ts +180 -0
- package/test/event.spec.ts +155 -0
- package/test/extra-validators.spec.ts +84 -0
- package/test/flow-logger.spec.ts +104 -0
- package/test/flow.spec.ts +508 -0
- package/test/input-stream.decorator.spec.ts +379 -0
- package/test/long-rpc.test.py +14 -0
- package/test/long-running-rpc.spec.ts +60 -0
- package/test/message.spec.ts +57 -0
- package/test/mocks/logger.mock.ts +7 -0
- package/test/mocks/nats-connection.mock.ts +135 -0
- package/test/mocks/nats-prepare.reals-nats.ts +15 -0
- package/test/rpc.spec.ts +198 -0
- package/test/rpc.test.py +45 -0
- package/test/rx.spec.ts +92 -0
- package/test/unit-decorator.spec.ts +57 -0
- package/test/utils.spec.ts +210 -0
- package/test/validation.spec.ts +174 -0
- package/tsconfig.json +13 -0
- package/tsconfig.lib.json +22 -0
- package/tsconfig.spec.json +8 -0
- package/LICENSE +0 -21
- package/dist/ContextManager.d.ts +0 -40
- package/dist/ContextManager.js +0 -77
- package/dist/FlowApplication.d.ts +0 -85
- package/dist/FlowApplication.js +0 -500
- package/dist/FlowElement.d.ts +0 -67
- package/dist/FlowElement.js +0 -163
- package/dist/FlowEvent.d.ts +0 -25
- package/dist/FlowEvent.js +0 -71
- package/dist/FlowLogger.d.ts +0 -44
- package/dist/FlowLogger.js +0 -94
- package/dist/FlowModule.d.ts +0 -7
- package/dist/FlowModule.js +0 -13
- package/dist/RpcClient.d.ts +0 -13
- package/dist/RpcClient.js +0 -84
- package/dist/TestModule.d.ts +0 -2
- package/dist/TestModule.js +0 -27
- package/dist/amqp.d.ts +0 -14
- package/dist/amqp.js +0 -12
- package/dist/extra-validators.d.ts +0 -1
- package/dist/extra-validators.js +0 -51
- package/dist/flow.interface.d.ts +0 -48
- package/dist/flow.interface.js +0 -9
- package/dist/index.js +0 -18
- package/dist/nats.d.ts +0 -12
- package/dist/nats.js +0 -109
- package/dist/unit-decorators.d.ts +0 -39
- package/dist/unit-decorators.js +0 -156
- package/dist/unit-utils.d.ts +0 -8
- package/dist/unit-utils.js +0 -143
- package/dist/units.d.ts +0 -31
- package/dist/units.js +0 -570
- package/dist/utils.d.ts +0 -51
- package/dist/utils.js +0 -137
- /package/{dist → src/lib}/rpc_server.py +0 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
|
|
3
|
+
import { EventLoopUtilization, performance } from 'perf_hooks';
|
|
4
|
+
|
|
5
|
+
import { API, HttpClientService, MockAPI } from '@hahnpro/hpc-api';
|
|
6
|
+
import { ConsumeOptions, Consumer, ConsumerMessages, DeliverPolicy, jetstreamManager } from '@nats-io/jetstream';
|
|
7
|
+
import { NatsConnection, ConnectionOptions as NatsConnectionOptions } from '@nats-io/nats-core';
|
|
8
|
+
import { AmqpConnectionManager, Channel, ChannelWrapper } from 'amqp-connection-manager';
|
|
9
|
+
import { CloudEvent } from 'cloudevents';
|
|
10
|
+
import { cloneDeep } from 'lodash';
|
|
11
|
+
import sizeof from 'object-sizeof';
|
|
12
|
+
import { PartialObserver, Subject } from 'rxjs';
|
|
13
|
+
import { mergeMap, tap } from 'rxjs/operators';
|
|
14
|
+
|
|
15
|
+
import { AmqpConnection, AmqpConnectionConfig, createAmqpConnection } from './amqp';
|
|
16
|
+
import { ContextManager } from './ContextManager';
|
|
17
|
+
import { ClassType, DeploymentMessage, Flow, FlowContext, FlowElementContext, LifecycleEvent, StreamOptions } from './flow.interface';
|
|
18
|
+
import type { FlowElement } from './FlowElement';
|
|
19
|
+
import { FlowEvent } from './FlowEvent';
|
|
20
|
+
import { FlowLogger, Logger } from './FlowLogger';
|
|
21
|
+
import {
|
|
22
|
+
createNatsConnection,
|
|
23
|
+
defaultConsumerConfig,
|
|
24
|
+
FLOWS_STREAM_NAME,
|
|
25
|
+
getOrCreateConsumer,
|
|
26
|
+
NatsEvent,
|
|
27
|
+
natsEventListener,
|
|
28
|
+
natsFlowsPrefixFlowDeployment,
|
|
29
|
+
publishNatsEvent,
|
|
30
|
+
} from './nats';
|
|
31
|
+
import { RpcClient } from './RpcClient';
|
|
32
|
+
import { delay, truncate } from './utils';
|
|
33
|
+
|
|
34
|
+
const MAX_EVENT_SIZE_BYTES = +process.env.MAX_EVENT_SIZE_BYTES || 512 * 1024; // 512kb
|
|
35
|
+
const WARN_EVENT_PROCESSING_SEC = +process.env.WARN_EVENT_PROCESSING_SEC || 60;
|
|
36
|
+
const WARN_EVENT_QUEUE_SIZE = +process.env.WARN_EVENT_QUEUE_SIZE || 100;
|
|
37
|
+
|
|
38
|
+
interface QueueMetrics {
|
|
39
|
+
size: number;
|
|
40
|
+
lastAdd: number;
|
|
41
|
+
lastRemove: number;
|
|
42
|
+
warnings: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface FlowAppConfig {
|
|
46
|
+
logger?: Logger;
|
|
47
|
+
amqpConfig?: AmqpConnectionConfig;
|
|
48
|
+
amqpConnection?: AmqpConnectionManager;
|
|
49
|
+
natsConfig?: NatsConnectionOptions;
|
|
50
|
+
natsConnection?: NatsConnection;
|
|
51
|
+
apiClient?: HttpClientService;
|
|
52
|
+
skipApi?: boolean;
|
|
53
|
+
explicitInit?: boolean;
|
|
54
|
+
mockApi?: MockAPI;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class FlowApplication {
|
|
58
|
+
private _api: API;
|
|
59
|
+
private _rpcClient: RpcClient;
|
|
60
|
+
private amqpChannel: ChannelWrapper;
|
|
61
|
+
private readonly amqpConnection: AmqpConnectionManager;
|
|
62
|
+
private readonly natsConnectionConfig?: NatsConnectionOptions;
|
|
63
|
+
private _natsConnection?: NatsConnection;
|
|
64
|
+
private readonly baseLogger: Logger;
|
|
65
|
+
private context: FlowContext;
|
|
66
|
+
private declarations: Record<string, ClassType<FlowElement>> = {};
|
|
67
|
+
private elements: Record<string, FlowElement> = {};
|
|
68
|
+
private initialized = false;
|
|
69
|
+
private readonly logger: FlowLogger;
|
|
70
|
+
private outputStreamMap = new Map<string, Subject<FlowEvent>>();
|
|
71
|
+
private outputQueueMetrics = new Map<string, QueueMetrics>();
|
|
72
|
+
private performanceMap = new Map<string, EventLoopUtilization>();
|
|
73
|
+
private readonly skipApi: boolean;
|
|
74
|
+
private readonly apiClient?: HttpClientService;
|
|
75
|
+
|
|
76
|
+
private readonly contextManager: ContextManager;
|
|
77
|
+
private natsMessageIterator: ConsumerMessages;
|
|
78
|
+
|
|
79
|
+
constructor(modules: ClassType<any>[], flow: Flow, config?: FlowAppConfig);
|
|
80
|
+
constructor(
|
|
81
|
+
modules: ClassType<any>[],
|
|
82
|
+
flow: Flow,
|
|
83
|
+
baseLogger?: Logger,
|
|
84
|
+
amqpConnection?: AmqpConnection,
|
|
85
|
+
natsConnection?: NatsConnection,
|
|
86
|
+
skipApi?: boolean,
|
|
87
|
+
explicitInit?: boolean,
|
|
88
|
+
);
|
|
89
|
+
constructor(
|
|
90
|
+
private modules: ClassType<any>[],
|
|
91
|
+
private flow: Flow,
|
|
92
|
+
baseLoggerOrConfig?: Logger | FlowAppConfig,
|
|
93
|
+
amqpConnection?: AmqpConnection,
|
|
94
|
+
natsConnection?: NatsConnection,
|
|
95
|
+
skipApi?: boolean,
|
|
96
|
+
explicitInit?: boolean,
|
|
97
|
+
mockApi?: MockAPI,
|
|
98
|
+
) {
|
|
99
|
+
if (baseLoggerOrConfig && !(baseLoggerOrConfig as Logger).log) {
|
|
100
|
+
const config = baseLoggerOrConfig as FlowAppConfig;
|
|
101
|
+
this.baseLogger = config.logger;
|
|
102
|
+
this.amqpConnection = config.amqpConnection || createAmqpConnection(config.amqpConfig);
|
|
103
|
+
this.natsConnectionConfig = config.natsConfig;
|
|
104
|
+
this._natsConnection = config.natsConnection;
|
|
105
|
+
this.skipApi = config.skipApi || false;
|
|
106
|
+
explicitInit = config.explicitInit || false;
|
|
107
|
+
this._api = config.mockApi || null;
|
|
108
|
+
this.apiClient = config.apiClient;
|
|
109
|
+
} else {
|
|
110
|
+
this.baseLogger = baseLoggerOrConfig as Logger;
|
|
111
|
+
this.amqpConnection = amqpConnection?.managedConnection;
|
|
112
|
+
this._natsConnection = natsConnection;
|
|
113
|
+
this.skipApi = skipApi || false;
|
|
114
|
+
explicitInit = explicitInit || false;
|
|
115
|
+
this._api = mockApi || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.logger = new FlowLogger(
|
|
119
|
+
{ id: 'none', functionFqn: 'FlowApplication', ...flow?.context },
|
|
120
|
+
this.baseLogger || undefined,
|
|
121
|
+
this.publishNatsEventFlowlogs,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
this.contextManager = new ContextManager(this.logger, this.flow?.properties);
|
|
125
|
+
|
|
126
|
+
process.once('uncaughtException', (err) => {
|
|
127
|
+
this.logger.error('Uncaught exception!');
|
|
128
|
+
this.logger.error(err);
|
|
129
|
+
this.destroy(1);
|
|
130
|
+
});
|
|
131
|
+
process.on('unhandledRejection', (reason) => {
|
|
132
|
+
this.logger.error('Unhandled promise rejection!');
|
|
133
|
+
this.logger.error(reason);
|
|
134
|
+
});
|
|
135
|
+
process.on('SIGTERM', () => {
|
|
136
|
+
this.logger.log('Flow Deployment is terminating');
|
|
137
|
+
this.destroy(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (explicitInit !== true) {
|
|
141
|
+
this.init();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get rpcClient(): RpcClient {
|
|
146
|
+
if (!this._rpcClient && this.amqpConnection) {
|
|
147
|
+
this._rpcClient = new RpcClient(this.amqpConnection, this.logger);
|
|
148
|
+
}
|
|
149
|
+
return this._rpcClient;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get api(): API {
|
|
153
|
+
return this._api;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get natsConnection(): NatsConnection {
|
|
157
|
+
return this._natsConnection;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public getContextManager(): ContextManager {
|
|
161
|
+
return this.contextManager;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public getProperties() {
|
|
165
|
+
return this.contextManager.getProperties();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async consumeNatsMessagesOfConsumer(consumer: Consumer, consumeOptions: ConsumeOptions) {
|
|
169
|
+
if (this.natsMessageIterator) {
|
|
170
|
+
await this.natsMessageIterator.close();
|
|
171
|
+
}
|
|
172
|
+
this.natsMessageIterator = await consumer.consume(consumeOptions);
|
|
173
|
+
for await (const msg of this.natsMessageIterator) {
|
|
174
|
+
try {
|
|
175
|
+
let event: CloudEvent;
|
|
176
|
+
try {
|
|
177
|
+
event = new CloudEvent(msg.json());
|
|
178
|
+
event.validate();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.logger.error('Message is not a valid CloudEvent and will be discarded');
|
|
181
|
+
msg.ack(); // Acknowledge the message to remove it from the queue
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
await this.onMessage(event);
|
|
185
|
+
msg.ack();
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this.logger.error('Error processing message');
|
|
188
|
+
this.logger.error(error);
|
|
189
|
+
msg.nak(1000);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
public async init() {
|
|
195
|
+
if (this.initialized) return;
|
|
196
|
+
|
|
197
|
+
this.context = { ...this.flow.context };
|
|
198
|
+
this.contextManager.overwriteAllProperties(this.flow.properties ?? {});
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
if (!this.skipApi && !(this._api instanceof MockAPI)) {
|
|
202
|
+
const { owner } = this.context;
|
|
203
|
+
// only create real API if it should not be skipped and is not already a mock
|
|
204
|
+
this._api = new API(this.apiClient, { activeOrg: owner?.id });
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
this.logger.error(err?.message || err);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const logErrorAndExit = async (err: string) => {
|
|
211
|
+
this.logger.error(new Error(err));
|
|
212
|
+
await this.destroy(1);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (!this._natsConnection && this.natsConnectionConfig) {
|
|
216
|
+
try {
|
|
217
|
+
this._natsConnection = await createNatsConnection(this.natsConnectionConfig);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
await logErrorAndExit(`Could not connect to the NATS-Servers: ${err}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this._natsConnection && !this._natsConnection.isClosed() && this.context?.deploymentId !== undefined) {
|
|
224
|
+
try {
|
|
225
|
+
const consumerOptions = {
|
|
226
|
+
...defaultConsumerConfig,
|
|
227
|
+
name: `flow-deployment-${this.context.deploymentId}`,
|
|
228
|
+
filter_subject: `${natsFlowsPrefixFlowDeployment}.${this.context.deploymentId}.*`,
|
|
229
|
+
inactive_threshold: 10 * 60 * 1_000_000_000, // 10 mins
|
|
230
|
+
deliver_policy: DeliverPolicy.New,
|
|
231
|
+
};
|
|
232
|
+
const consumer = await getOrCreateConsumer(
|
|
233
|
+
this.logger,
|
|
234
|
+
this._natsConnection,
|
|
235
|
+
FLOWS_STREAM_NAME,
|
|
236
|
+
consumerOptions.name,
|
|
237
|
+
consumerOptions,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Recreate consumers on reconnects: NO AWAIT, listen asynchronously
|
|
241
|
+
const handleNatsStatus = async () => {
|
|
242
|
+
try {
|
|
243
|
+
this.logger.debug('ConsumerService: Reconnected to Nats and re-creating non-durable consumers');
|
|
244
|
+
await getOrCreateConsumer(this.logger, this._natsConnection, FLOWS_STREAM_NAME, consumerOptions.name, consumerOptions);
|
|
245
|
+
this.consumeNatsMessagesOfConsumer(consumer, { expires: 10 * 1_000_000_000 /* 10 seconds */ });
|
|
246
|
+
} catch (e) {
|
|
247
|
+
this.logger.error('NATS Status-AsyncIterator is not available, cannot listen. Due to error:');
|
|
248
|
+
this.logger.error(e);
|
|
249
|
+
natsEventListener(this._natsConnection, this.logger, handleNatsStatus);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
natsEventListener(this._natsConnection, this.logger, handleNatsStatus);
|
|
253
|
+
|
|
254
|
+
// NO AWAIT, listen for messages of the consumer asynchronously
|
|
255
|
+
this.consumeNatsMessagesOfConsumer(consumer, { expires: 10 * 1_000_000_000 /* 10 seconds */ });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
await logErrorAndExit(`Could not set up consumer for deployment messages exchanges: ${e}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.amqpChannel = this.amqpConnection?.createChannel({
|
|
262
|
+
json: true,
|
|
263
|
+
setup: async (channel: Channel) => {
|
|
264
|
+
try {
|
|
265
|
+
await channel.assertExchange('flow', 'direct', { durable: true }); // TODO wieso weshalb warum: wo wird das gebraucht?
|
|
266
|
+
} catch (e) {
|
|
267
|
+
await logErrorAndExit(`Could not assert exchanges: ${e}`);
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (this.amqpChannel) {
|
|
273
|
+
await this.amqpChannel.waitForConnect();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const module of this.modules) {
|
|
277
|
+
const moduleName = Reflect.getMetadata('module:name', module);
|
|
278
|
+
const moduleDeclarations = Reflect.getMetadata('module:declarations', module);
|
|
279
|
+
if (!moduleName || !moduleDeclarations || !Array.isArray(moduleDeclarations)) {
|
|
280
|
+
await logErrorAndExit(`FlowModule (${module.name}) metadata is missing or invalid`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
for (const declaration of moduleDeclarations) {
|
|
284
|
+
const functionFqn = Reflect.getMetadata('element:functionFqn', declaration);
|
|
285
|
+
if (!functionFqn) {
|
|
286
|
+
await logErrorAndExit(`FlowFunction (${declaration.name}) metadata is missing or invalid`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.declarations[`${moduleName}.${functionFqn}`] = declaration;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const element of this.flow.elements) {
|
|
294
|
+
const { id, name, properties, module, functionFqn } = element;
|
|
295
|
+
try {
|
|
296
|
+
const context: Context = { ...this.context, id, name, logger: this.baseLogger, app: this };
|
|
297
|
+
this.elements[id] = new this.declarations[`${module}.${functionFqn}`](
|
|
298
|
+
context,
|
|
299
|
+
// run recursively through all properties and interpolate them / replace them with their explicit value
|
|
300
|
+
this.contextManager.replaceAllPlaceholderProperties(properties),
|
|
301
|
+
);
|
|
302
|
+
this.elements[id].setPropertiesWithPlaceholders(cloneDeep(properties));
|
|
303
|
+
} catch (err) {
|
|
304
|
+
await logErrorAndExit(`Could not create FlowElement for ${module}.${functionFqn}`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const connection of this.flow.connections) {
|
|
310
|
+
const { source, target, sourceStream = 'default', targetStream = 'default' } = connection;
|
|
311
|
+
if (!source || !target) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sourceStreamId = `${source}.${sourceStream}`;
|
|
316
|
+
const targetStreamId = `${target}.${targetStream}`;
|
|
317
|
+
const element = this.elements[target];
|
|
318
|
+
|
|
319
|
+
if (!element || !element.constructor) {
|
|
320
|
+
await logErrorAndExit(`${target} has not been initialized`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const streamHandler = Reflect.getMetadata(`stream:${targetStream}`, element.constructor);
|
|
324
|
+
if (!streamHandler || !element[streamHandler]) {
|
|
325
|
+
await logErrorAndExit(`${target} does not implement a handler for ${targetStream}`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const streamOptions: StreamOptions = Reflect.getMetadata(`stream:options:${targetStream}`, element.constructor) || {};
|
|
330
|
+
const concurrent = streamOptions.concurrent || 1;
|
|
331
|
+
|
|
332
|
+
const outputStream = this.getOutputStream(sourceStreamId);
|
|
333
|
+
outputStream
|
|
334
|
+
.pipe(
|
|
335
|
+
tap(() => this.setQueueMetrics(targetStreamId)),
|
|
336
|
+
mergeMap(async (event: FlowEvent) => {
|
|
337
|
+
const eventId = event.getId();
|
|
338
|
+
this.publishLifecycleEvent(element, eventId, LifecycleEvent.ACTIVATED);
|
|
339
|
+
this.performanceMap.set(eventId, performance.eventLoopUtilization());
|
|
340
|
+
const start = performance.now();
|
|
341
|
+
try {
|
|
342
|
+
await element[streamHandler](event);
|
|
343
|
+
const duration = Math.ceil(performance.now() - start);
|
|
344
|
+
this.publishLifecycleEvent(element, eventId, LifecycleEvent.COMPLETED, { duration });
|
|
345
|
+
} catch (err) {
|
|
346
|
+
const duration = Math.ceil(performance.now() - start);
|
|
347
|
+
this.publishLifecycleEvent(element, eventId, LifecycleEvent.TERMINATED, { duration });
|
|
348
|
+
try {
|
|
349
|
+
element.handleApiError(err);
|
|
350
|
+
} catch (e) {
|
|
351
|
+
this.logger.error(err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return event;
|
|
355
|
+
}, concurrent),
|
|
356
|
+
tap((event: FlowEvent) => {
|
|
357
|
+
this.updateMetrics(targetStreamId);
|
|
358
|
+
let elu = this.performanceMap.get(event.getId());
|
|
359
|
+
if (elu) {
|
|
360
|
+
this.performanceMap.delete(event.getId());
|
|
361
|
+
elu = performance.eventLoopUtilization(elu);
|
|
362
|
+
if (elu.utilization > 0.75 && elu.active > 2000) {
|
|
363
|
+
this.logger.warn(
|
|
364
|
+
`High event loop utilization detected for ${targetStreamId} with event ${event.getId()}! Handler was active for ${Number(
|
|
365
|
+
elu.active,
|
|
366
|
+
).toFixed(2)}ms with a utilization of ${Number(elu.utilization * 100).toFixed(
|
|
367
|
+
2,
|
|
368
|
+
)}%. Consider refactoring or move tasks to a worker thread.`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}),
|
|
373
|
+
)
|
|
374
|
+
.subscribe();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this.initialized = true;
|
|
378
|
+
this.logger.log('Flow Deployment is running');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private publishLifecycleEvent = async (
|
|
382
|
+
element: FlowElement,
|
|
383
|
+
flowEventId: string,
|
|
384
|
+
eventType: LifecycleEvent,
|
|
385
|
+
data: Record<string, any> = {},
|
|
386
|
+
) => {
|
|
387
|
+
try {
|
|
388
|
+
const { flowId, deploymentId, id: elementId, functionFqn, inputStreamId } = element.getMetadata();
|
|
389
|
+
const natsEvent: NatsEvent<any> = {
|
|
390
|
+
source: `flows/${flowId}/deployments/${deploymentId}/elements/${elementId}`,
|
|
391
|
+
type: eventType,
|
|
392
|
+
data: {
|
|
393
|
+
flowEventId,
|
|
394
|
+
functionFqn,
|
|
395
|
+
inputStreamId,
|
|
396
|
+
...data,
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
await publishNatsEvent(
|
|
400
|
+
this.logger,
|
|
401
|
+
this._natsConnection,
|
|
402
|
+
natsEvent,
|
|
403
|
+
`${natsFlowsPrefixFlowDeployment}.flowlifecycle.${deploymentId}`,
|
|
404
|
+
);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
this.logger.error(err);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
private setQueueMetrics = (id: string) => {
|
|
411
|
+
const metrics = this.outputQueueMetrics.get(id) || { size: 0, lastAdd: 0, lastRemove: Date.now(), warnings: 0 };
|
|
412
|
+
const secsProcessing = Math.round((metrics.lastAdd - metrics.lastRemove) / 1000);
|
|
413
|
+
metrics.size++;
|
|
414
|
+
metrics.lastAdd = Date.now();
|
|
415
|
+
|
|
416
|
+
if (secsProcessing >= WARN_EVENT_PROCESSING_SEC * (metrics.warnings + 1)) {
|
|
417
|
+
this.logger.warn(
|
|
418
|
+
`Input stream "${id}" has ${metrics.size} queued events and the last event has been processing for ${secsProcessing}s`,
|
|
419
|
+
);
|
|
420
|
+
metrics.warnings++;
|
|
421
|
+
} else if (metrics.size % WARN_EVENT_QUEUE_SIZE === 0) {
|
|
422
|
+
this.logger.warn(`Input stream "${id}" has ${metrics.size} queued events`);
|
|
423
|
+
}
|
|
424
|
+
this.outputQueueMetrics.set(id, metrics);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
private updateMetrics = (id: string) => {
|
|
428
|
+
const metrics = this.outputQueueMetrics.get(id);
|
|
429
|
+
if (metrics) {
|
|
430
|
+
metrics.size = metrics.size > 0 ? metrics.size - 1 : 0;
|
|
431
|
+
metrics.lastRemove = Date.now();
|
|
432
|
+
metrics.warnings = 0;
|
|
433
|
+
this.outputQueueMetrics.set(id, metrics);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
public subscribe = (streamId: string, observer: PartialObserver<FlowEvent>) => this.getOutputStream(streamId).subscribe(observer);
|
|
438
|
+
|
|
439
|
+
public emit = (event: FlowEvent) => {
|
|
440
|
+
if (event) {
|
|
441
|
+
try {
|
|
442
|
+
this.publishNatsEventFlowlogs(event);
|
|
443
|
+
if (this.outputStreamMap.has(event.getStreamId())) {
|
|
444
|
+
this.getOutputStream(event.getStreamId()).next(event);
|
|
445
|
+
}
|
|
446
|
+
} catch (err) {
|
|
447
|
+
this.logger.error(err);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
public emitPartial = (completeEvent: FlowEvent, partialEvent: FlowEvent) => {
|
|
453
|
+
// send complete event, log only partial event
|
|
454
|
+
try {
|
|
455
|
+
if (completeEvent && this.outputStreamMap.has(completeEvent.getStreamId())) {
|
|
456
|
+
this.getOutputStream(completeEvent.getStreamId()).next(completeEvent);
|
|
457
|
+
}
|
|
458
|
+
if (partialEvent) {
|
|
459
|
+
this.publishNatsEventFlowlogs(partialEvent);
|
|
460
|
+
}
|
|
461
|
+
} catch (err) {
|
|
462
|
+
this.logger.error(err);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
public onMessage = async (cloudEvent: CloudEvent) => {
|
|
467
|
+
if (cloudEvent.subject.endsWith('.update')) {
|
|
468
|
+
try {
|
|
469
|
+
const flow: Flow = cloudEvent.data;
|
|
470
|
+
if (!flow) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let context: Partial<FlowElementContext> = {};
|
|
475
|
+
if (flow.context) {
|
|
476
|
+
this.context = { ...this.context, ...flow.context };
|
|
477
|
+
context = this.context;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (flow.properties) {
|
|
481
|
+
this.contextManager.updateFlowProperties(flow.properties);
|
|
482
|
+
for (const element of Object.values(this.elements)) {
|
|
483
|
+
element.replacePlaceholderAndSetProperties();
|
|
484
|
+
element.onFlowPropertiesChanged?.(flow.properties);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (Object.keys(context).length > 0) {
|
|
489
|
+
for (const element of flow.elements || []) {
|
|
490
|
+
context = { ...context, name: element.name };
|
|
491
|
+
this.elements?.[element.id]?.onContextChanged(context);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (const element of flow.elements || []) {
|
|
496
|
+
this.elements?.[element.id]?.setPropertiesWithPlaceholders(cloneDeep(element.properties));
|
|
497
|
+
this.elements?.[element.id]?.onPropertiesChanged(
|
|
498
|
+
this.contextManager.replaceAllPlaceholderProperties(this.elements[element.id].getPropertiesWithPlaceholders()),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const natsEvent = {
|
|
503
|
+
source: `hpc/flow-application`,
|
|
504
|
+
type: `${natsFlowsPrefixFlowDeployment}.health`,
|
|
505
|
+
subject: `${this.context.deploymentId}`,
|
|
506
|
+
data: {
|
|
507
|
+
deploymentId: this.context.deploymentId,
|
|
508
|
+
status: 'updated',
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
await publishNatsEvent(this.logger, this._natsConnection, natsEvent);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
this.logger.error(err);
|
|
514
|
+
|
|
515
|
+
const natsEvent = {
|
|
516
|
+
source: `hpc/flow-application`,
|
|
517
|
+
type: `${natsFlowsPrefixFlowDeployment}.health`,
|
|
518
|
+
subject: `${this.context.deploymentId}`,
|
|
519
|
+
data: {
|
|
520
|
+
deploymentId: this.context.deploymentId,
|
|
521
|
+
status: 'updating failed',
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
await publishNatsEvent(this.logger, this._natsConnection, natsEvent);
|
|
525
|
+
}
|
|
526
|
+
} else if (cloudEvent.subject.endsWith('.message')) {
|
|
527
|
+
const data = cloudEvent.data as DeploymentMessage;
|
|
528
|
+
const elementId = data?.elementId;
|
|
529
|
+
if (elementId) {
|
|
530
|
+
this.elements?.[elementId]?.onMessage?.(data);
|
|
531
|
+
} else {
|
|
532
|
+
for (const element of Object.values(this.elements)) {
|
|
533
|
+
element?.onMessage?.(data);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} else if (cloudEvent.subject.endsWith('.destroy')) {
|
|
537
|
+
// TODO war com.flowstudio.deployment.destroy in RabbitMq: wo wird das jetzt wieder gesendet?
|
|
538
|
+
this.destroy();
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Publish a flow event to the amqp flowlogs exchange.
|
|
544
|
+
* If the event size exceeds the limit it will be truncated
|
|
545
|
+
*
|
|
546
|
+
* TODO warum darf hier nicht false zurückgegeben werden? -> erzeugt loop
|
|
547
|
+
*/
|
|
548
|
+
public publishNatsEventFlowlogs = async (event: FlowEvent): Promise<boolean> => {
|
|
549
|
+
if (!this._natsConnection || this._natsConnection.isClosed()) {
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
const formatedEvent = event.format();
|
|
555
|
+
if (sizeof(formatedEvent) > MAX_EVENT_SIZE_BYTES) {
|
|
556
|
+
formatedEvent.data = truncate(formatedEvent.data);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const natsEvent = {
|
|
560
|
+
source: `hpc/flow-application`,
|
|
561
|
+
type: `${natsFlowsPrefixFlowDeployment}.flowlogs`,
|
|
562
|
+
subject: `${this.context.deploymentId}`,
|
|
563
|
+
data: formatedEvent,
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
await publishNatsEvent(this.logger, this._natsConnection, natsEvent);
|
|
567
|
+
return true;
|
|
568
|
+
} catch (err) {
|
|
569
|
+
this.logger.error(err);
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Calls onDestroy lifecycle method on all flow elements,
|
|
576
|
+
* closes amqp connection after allowing logs to be processed and published
|
|
577
|
+
* then exits process
|
|
578
|
+
*/
|
|
579
|
+
public async destroy(exitCode = 0) {
|
|
580
|
+
try {
|
|
581
|
+
try {
|
|
582
|
+
for (const element of Object.values(this.elements)) {
|
|
583
|
+
element?.onDestroy?.();
|
|
584
|
+
}
|
|
585
|
+
if (this._rpcClient) {
|
|
586
|
+
await this._rpcClient.close();
|
|
587
|
+
}
|
|
588
|
+
} catch (err) {
|
|
589
|
+
this.logger.error(err);
|
|
590
|
+
}
|
|
591
|
+
// allow time for logs to be processed
|
|
592
|
+
await delay(250);
|
|
593
|
+
if (this.amqpConnection) {
|
|
594
|
+
await this.amqpConnection.close();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Close all output streams
|
|
598
|
+
for (const [id, stream] of this.outputStreamMap.entries()) {
|
|
599
|
+
try {
|
|
600
|
+
stream?.complete();
|
|
601
|
+
} catch (err) {
|
|
602
|
+
this.logger.error(`Error completing output stream ${id}: ${err.message}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Nats: Delete consumer for flow deployment, stop message listening and close connection
|
|
607
|
+
try {
|
|
608
|
+
await this.natsMessageIterator?.close();
|
|
609
|
+
await this._natsConnection?.drain();
|
|
610
|
+
await this._natsConnection?.close();
|
|
611
|
+
|
|
612
|
+
if (this._natsConnection && !this._natsConnection.isClosed()) {
|
|
613
|
+
await jetstreamManager(this._natsConnection).then((jsm) => {
|
|
614
|
+
jsm.consumers
|
|
615
|
+
.delete(FLOWS_STREAM_NAME, `flow-deployment-${this.context?.deploymentId}`)
|
|
616
|
+
.then(() => {
|
|
617
|
+
this.logger.debug(`Deleted consumer for flow deployment ${this.context?.deploymentId}`);
|
|
618
|
+
})
|
|
619
|
+
.catch((err) => {
|
|
620
|
+
this.logger.error(`Could not delete consumer for flow deployment ${this.context?.deploymentId}: ${err.message}`);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
} catch (err) {
|
|
625
|
+
this.logger.error(err);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// remove process listeners
|
|
629
|
+
process.removeAllListeners('SIGTERM');
|
|
630
|
+
process.removeAllListeners('uncaughtException');
|
|
631
|
+
process.removeAllListeners('unhandledRejection');
|
|
632
|
+
} catch (err) {
|
|
633
|
+
/* eslint-disable-next-line no-console */
|
|
634
|
+
console.error(err);
|
|
635
|
+
} finally {
|
|
636
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
637
|
+
process.exit(exitCode);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Returns rxjs subject for the specified stream id.
|
|
644
|
+
* A new subject will be created if one doesn't exist yet.
|
|
645
|
+
*/
|
|
646
|
+
private getOutputStream(id: string) {
|
|
647
|
+
const stream = this.outputStreamMap.get(id);
|
|
648
|
+
if (!stream) {
|
|
649
|
+
this.outputStreamMap.set(id, new Subject<FlowEvent>());
|
|
650
|
+
return this.outputStreamMap.get(id);
|
|
651
|
+
}
|
|
652
|
+
return stream;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export interface Context extends FlowElementContext {
|
|
657
|
+
app?: FlowApplication;
|
|
658
|
+
logger?: Logger;
|
|
659
|
+
}
|