@cap-js-community/event-queue 2.1.0-beta.3 → 2.1.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/db/Event.cds +1 -1
- package/package.json +8 -9
- package/src/config.js +25 -14
- package/src/dbHandler.js +105 -24
- package/src/initialize.js +4 -0
- package/src/outbox/EventQueueGenericOutboxHandler.js +68 -11
- package/src/runner/openEvents.js +6 -6
- package/src/runner/runner.js +38 -1
- package/src/shared/eventQueueStats.js +209 -0
- package/src/shared/openTelemetry.js +79 -1
package/db/Event.cds
CHANGED
|
@@ -16,7 +16,7 @@ entity Event: cuid {
|
|
|
16
16
|
type: String not null;
|
|
17
17
|
subType: String not null;
|
|
18
18
|
referenceEntity: String;
|
|
19
|
-
referenceEntityKey:
|
|
19
|
+
referenceEntityKey: String;
|
|
20
20
|
status: Status default 0 not null;
|
|
21
21
|
payload: LargeString;
|
|
22
22
|
attempts: Integer default 0 not null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js-community/event-queue",
|
|
3
|
-
"version": "2.1.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "An event queue that enables secure transactional processing of asynchronous and periodic events, featuring instant event processing with Redis Pub/Sub and load distribution across all application instances.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"node": ">=20"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@cap-js-community/common": "^0.
|
|
48
|
-
"@sap/xssec": "^4.
|
|
47
|
+
"@cap-js-community/common": "^0.4.0",
|
|
48
|
+
"@sap/xssec": "^4.13.0",
|
|
49
49
|
"cron-parser": "^5.5.0",
|
|
50
50
|
"verror": "^1.10.1",
|
|
51
|
-
"yaml": "^2.8.
|
|
51
|
+
"yaml": "^2.8.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@actions/core": "^2.0.2",
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"@cap-js/db-service": "^2.8.2",
|
|
57
57
|
"@cap-js/hana": "^2.6.0",
|
|
58
58
|
"@cap-js/sqlite": "^2.1.3",
|
|
59
|
-
"@opentelemetry/api": "^1.9.
|
|
60
|
-
"@sap/cds": "^9.
|
|
61
|
-
"@sap/cds-dk": "^9.
|
|
59
|
+
"@opentelemetry/api": "^1.9.1",
|
|
60
|
+
"@sap/cds": "^9.8.4",
|
|
61
|
+
"@sap/cds-dk": "^9.8.2",
|
|
62
62
|
"eslint": "^8.57.1",
|
|
63
63
|
"eslint-config-prettier": "^10.1.8",
|
|
64
64
|
"eslint-plugin-jest": "^29.12.1",
|
|
@@ -100,8 +100,7 @@
|
|
|
100
100
|
"requires": {
|
|
101
101
|
"xsuaa-eventQueue": {
|
|
102
102
|
"vcap": {
|
|
103
|
-
"label": "xsuaa"
|
|
104
|
-
"plan": "application"
|
|
103
|
+
"label": "xsuaa"
|
|
105
104
|
}
|
|
106
105
|
},
|
|
107
106
|
"redis-eventQueue": {
|
package/src/config.js
CHANGED
|
@@ -30,6 +30,7 @@ const UTC_DEFAULT = false;
|
|
|
30
30
|
const USE_CRON_TZ_DEFAULT = true;
|
|
31
31
|
const SAGA_SUCCESS = "#succeeded";
|
|
32
32
|
const SAGA_FAILED = "#failed";
|
|
33
|
+
const SAGA_DONE = "#done";
|
|
33
34
|
|
|
34
35
|
const BASE_TABLES = {
|
|
35
36
|
EVENT: "sap.eventqueue.Event",
|
|
@@ -107,6 +108,7 @@ class Config {
|
|
|
107
108
|
#redisOptions;
|
|
108
109
|
#insertEventsBeforeCommit;
|
|
109
110
|
#enableTelemetry;
|
|
111
|
+
#collectEventQueueMetrics;
|
|
110
112
|
#unsubscribeHandlers = [];
|
|
111
113
|
#unsubscribedTenants = {};
|
|
112
114
|
#cronTimezone;
|
|
@@ -386,19 +388,20 @@ class Config {
|
|
|
386
388
|
result.adHoc
|
|
387
389
|
);
|
|
388
390
|
result.adHoc[key] = specificEventConfig;
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
391
|
+
for (const sagaSuffix of [SAGA_SUCCESS, SAGA_DONE, SAGA_FAILED]) {
|
|
392
|
+
if (config.events[sagaSuffix]) {
|
|
393
|
+
const [adHocKey, sagaSpecificEventConfig] = this.addCAPOutboxEventSpecificAction(
|
|
394
|
+
srvConfig,
|
|
395
|
+
name,
|
|
396
|
+
fnName,
|
|
397
|
+
result.adHoc
|
|
398
|
+
);
|
|
399
|
+
result.adHoc[adHocKey] = sagaSpecificEventConfig;
|
|
400
|
+
} else {
|
|
401
|
+
const sagaConfig = { ...specificEventConfig };
|
|
402
|
+
sagaConfig.subType = [sagaConfig.subType, sagaSuffix].join("/");
|
|
403
|
+
result.adHoc[[key, sagaSuffix].join("/")] = sagaConfig;
|
|
404
|
+
}
|
|
402
405
|
}
|
|
403
406
|
}
|
|
404
407
|
}
|
|
@@ -433,7 +436,7 @@ class Config {
|
|
|
433
436
|
}
|
|
434
437
|
|
|
435
438
|
const [withoutSaga, sagaSuffix] = action.split("/");
|
|
436
|
-
if ([SAGA_FAILED, SAGA_SUCCESS].includes(sagaSuffix)) {
|
|
439
|
+
if ([SAGA_FAILED, SAGA_SUCCESS, SAGA_DONE].includes(sagaSuffix)) {
|
|
437
440
|
if (config?.events?.[withoutSaga]) {
|
|
438
441
|
return this.#mixCAPPropertyNamesWithEventQueueNames(config.events[withoutSaga]);
|
|
439
442
|
}
|
|
@@ -901,6 +904,14 @@ class Config {
|
|
|
901
904
|
return this.#enableTelemetry;
|
|
902
905
|
}
|
|
903
906
|
|
|
907
|
+
set collectEventQueueMetrics(value) {
|
|
908
|
+
this.#collectEventQueueMetrics = value;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
get collectEventQueueMetrics() {
|
|
912
|
+
return this.#collectEventQueueMetrics;
|
|
913
|
+
}
|
|
914
|
+
|
|
904
915
|
get isMultiTenancy() {
|
|
905
916
|
return !!cds.requires.multitenancy;
|
|
906
917
|
}
|
package/src/dbHandler.js
CHANGED
|
@@ -4,11 +4,14 @@ const cds = require("@sap/cds");
|
|
|
4
4
|
|
|
5
5
|
const redisPub = require("./redis/redisPub");
|
|
6
6
|
const config = require("./config");
|
|
7
|
+
const eventQueueStats = require("./shared/eventQueueStats");
|
|
8
|
+
const { EventProcessingStatus } = require("./constants");
|
|
7
9
|
|
|
8
10
|
const COMPONENT_NAME = "/eventQueue/dbHandler";
|
|
9
11
|
const registeredHandlers = {
|
|
10
12
|
eventQueueDbHandler: false,
|
|
11
13
|
beforeDbHandler: false,
|
|
14
|
+
updateDbHandler: false,
|
|
12
15
|
};
|
|
13
16
|
|
|
14
17
|
const registerEventQueueDbHandler = (dbService) => {
|
|
@@ -26,36 +29,114 @@ const registerEventQueueDbHandler = (dbService) => {
|
|
|
26
29
|
req.tx._.eventQueuePublishEvents = req.tx._.eventQueuePublishEvents ?? {};
|
|
27
30
|
const eventQueuePublishEvents = req.tx._.eventQueuePublishEvents;
|
|
28
31
|
const data = Array.isArray(req.query.INSERT.entries) ? req.query.INSERT.entries : [req.query.INSERT.entries];
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
eventQueuePublishEvents[key]
|
|
35
|
-
) {
|
|
36
|
-
return result;
|
|
37
|
-
}
|
|
32
|
+
|
|
33
|
+
req.tx._.eventQueueStatsOpenCount = (req.tx._.eventQueueStatsOpenCount ?? 0) + data.length;
|
|
34
|
+
const newCombinations = data.reduce((result, event) => {
|
|
35
|
+
const key = [event.type, event.subType, event.namespace].join("##");
|
|
36
|
+
if (config.hasEventAfterCommitFlag(event.type, event.subType, event.namespace) && !eventQueuePublishEvents[key]) {
|
|
38
37
|
eventQueuePublishEvents[key] = true;
|
|
39
|
-
result
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
);
|
|
38
|
+
result.push(key);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}, []);
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
req.tx._.eventQueueBroadcastCombinations ??= [];
|
|
44
|
+
req.tx._.eventQueueBroadcastCombinations.push(...newCombinations);
|
|
45
|
+
if (!req.tx._.eventQueueSucceededHandlerRegistered) {
|
|
46
|
+
req.tx._.eventQueueSucceededHandlerRegistered = true;
|
|
45
47
|
req.on("succeeded", () => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
if (config.collectEventQueueMetrics && config.redisEnabled && req.tx._.eventQueueStatsOpenCount) {
|
|
49
|
+
eventQueueStats
|
|
50
|
+
.incrementCounters(req.tenant, eventQueueStats.StatusField.Pending, req.tx._.eventQueueStatsOpenCount)
|
|
51
|
+
.catch((err) => {
|
|
52
|
+
cds.log(COMPONENT_NAME).error("db handler failure during updating event stats", err, {
|
|
53
|
+
tenant: req.tenant,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const combinations = req.tx._.eventQueueBroadcastCombinations;
|
|
58
|
+
if (combinations.length) {
|
|
59
|
+
const events = combinations.map((combination) => {
|
|
60
|
+
const [type, subType, namespace] = combination.split("##");
|
|
61
|
+
return { type, subType, namespace };
|
|
55
62
|
});
|
|
56
|
-
|
|
63
|
+
redisPub.broadcastEvent(req.tenant, events).catch((err) => {
|
|
64
|
+
cds.log(COMPONENT_NAME).error("db handler failure during broadcasting event", err, {
|
|
65
|
+
tenant: req.tenant,
|
|
66
|
+
events,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
57
70
|
});
|
|
71
|
+
}
|
|
58
72
|
});
|
|
73
|
+
|
|
74
|
+
if (!registeredHandlers.updateDbHandler) {
|
|
75
|
+
if (!config.collectEventQueueMetrics || !config.redisEnabled) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
registeredHandlers.updateDbHandler = true;
|
|
79
|
+
dbService.after("UPDATE", def, (count, req) => {
|
|
80
|
+
const newStatus = req.query.UPDATE?.data?.status;
|
|
81
|
+
if (newStatus == null) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
req.tx._ = req.tx._ ?? {};
|
|
86
|
+
req.tx._.eventQueueStatsPendingDelta = req.tx._.eventQueueStatsPendingDelta ?? 0;
|
|
87
|
+
req.tx._.eventQueueStatsInProgressDelta = req.tx._.eventQueueStatsInProgressDelta ?? 0;
|
|
88
|
+
|
|
89
|
+
if (newStatus === EventProcessingStatus.InProgress) {
|
|
90
|
+
req.tx._.eventQueueStatsPendingDelta -= count;
|
|
91
|
+
req.tx._.eventQueueStatsInProgressDelta += count;
|
|
92
|
+
} else if (newStatus === EventProcessingStatus.Error) {
|
|
93
|
+
req.tx._.eventQueueStatsInProgressDelta -= count;
|
|
94
|
+
req.tx._.eventQueueStatsPendingDelta += count;
|
|
95
|
+
} else if (
|
|
96
|
+
newStatus === EventProcessingStatus.Done ||
|
|
97
|
+
newStatus === EventProcessingStatus.Exceeded ||
|
|
98
|
+
newStatus === EventProcessingStatus.Suspended
|
|
99
|
+
) {
|
|
100
|
+
req.tx._.eventQueueStatsInProgressDelta -= count;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!req.tx._.eventQueueUpdateSucceededHandlerRegistered) {
|
|
104
|
+
req.tx._.eventQueueUpdateSucceededHandlerRegistered = true;
|
|
105
|
+
req.on("succeeded", () => {
|
|
106
|
+
if (!config.redisEnabled) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const pendingDelta = req.tx._.eventQueueStatsPendingDelta;
|
|
110
|
+
const inProgressDelta = req.tx._.eventQueueStatsInProgressDelta;
|
|
111
|
+
const ops = [];
|
|
112
|
+
|
|
113
|
+
if (pendingDelta !== 0) {
|
|
114
|
+
ops.push(
|
|
115
|
+
eventQueueStats.adjustTenantCounter(req.tenant, eventQueueStats.StatusField.Pending, pendingDelta),
|
|
116
|
+
eventQueueStats.adjustGlobalCounter(eventQueueStats.StatusField.Pending, pendingDelta)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (inProgressDelta !== 0) {
|
|
120
|
+
ops.push(
|
|
121
|
+
eventQueueStats.adjustTenantCounter(req.tenant, eventQueueStats.StatusField.InProgress, inProgressDelta),
|
|
122
|
+
eventQueueStats.adjustGlobalCounter(eventQueueStats.StatusField.InProgress, inProgressDelta)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
Promise.allSettled(ops).then((results) => {
|
|
126
|
+
for (const result of results) {
|
|
127
|
+
if (result.status === "rejected") {
|
|
128
|
+
cds
|
|
129
|
+
.log(COMPONENT_NAME)
|
|
130
|
+
.error("db handler failure during updating event stats on update", result.reason, {
|
|
131
|
+
tenant: req.tenant,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
59
140
|
};
|
|
60
141
|
|
|
61
142
|
module.exports = {
|
package/src/initialize.js
CHANGED
|
@@ -17,6 +17,7 @@ const { getAllTenantIds } = require("./shared/cdsHelper");
|
|
|
17
17
|
const { EventProcessingStatus } = require("./constants");
|
|
18
18
|
const distributedLock = require("./shared/distributedLock");
|
|
19
19
|
const EventQueueError = require("./EventQueueError");
|
|
20
|
+
const { initMetrics } = require("./shared/openTelemetry");
|
|
20
21
|
|
|
21
22
|
const readFileAsync = promisify(fs.readFile);
|
|
22
23
|
|
|
@@ -49,6 +50,7 @@ const CONFIG_VARS = [
|
|
|
49
50
|
["disableProcessingOfSuspendedTenants", true],
|
|
50
51
|
["namespace", "default"],
|
|
51
52
|
["processingNamespaces", ["default"]],
|
|
53
|
+
["collectEventQueueMetrics", false],
|
|
52
54
|
];
|
|
53
55
|
|
|
54
56
|
/**
|
|
@@ -78,6 +80,7 @@ const CONFIG_VARS = [
|
|
|
78
80
|
* @param {string} [options.crashOnRedisUnavailable=true] - If enabled an error is thrown if the redis connection check is not successful
|
|
79
81
|
* @param {string} [options.namespace=default] - Default namespace in which events are published
|
|
80
82
|
* @param {string} [options.processingNamespaces=[default]] - Namespaces which the application processes
|
|
83
|
+
* @param {boolean} [options.collectEventQueueMetrics=false] - Enable collection of event queue metrics (pending/inProgress counters) stored in Redis and exposed via OpenTelemetry gauges.
|
|
81
84
|
*/
|
|
82
85
|
const initialize = async (options = {}) => {
|
|
83
86
|
if (config.initialized) {
|
|
@@ -125,6 +128,7 @@ const initialize = async (options = {}) => {
|
|
|
125
128
|
runInterval: config.runInterval,
|
|
126
129
|
useAsCAPQueue: config.useAsCAPQueue,
|
|
127
130
|
});
|
|
131
|
+
initMetrics();
|
|
128
132
|
resolveFn();
|
|
129
133
|
};
|
|
130
134
|
|
|
@@ -16,8 +16,18 @@ const EVENT_QUEUE_ACTIONS = {
|
|
|
16
16
|
CHECK_AND_ADJUST: "eventQueueCheckAndAdjustPayload",
|
|
17
17
|
SAGA_SUCCESS: "#succeeded",
|
|
18
18
|
SAGA_FAILED: "#failed",
|
|
19
|
+
SAGA_DONE: "#done",
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
const PROPAGATE_EVENT_QUEUE_ENTRIES = [
|
|
23
|
+
"ID",
|
|
24
|
+
"lastAttempTimestamp",
|
|
25
|
+
"payload",
|
|
26
|
+
"referenceEntity",
|
|
27
|
+
"referenceEntityKey",
|
|
28
|
+
"status",
|
|
29
|
+
];
|
|
30
|
+
|
|
21
31
|
class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
22
32
|
constructor(context, eventType, eventSubType, config) {
|
|
23
33
|
super(context, eventType, eventSubType, config);
|
|
@@ -312,7 +322,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
312
322
|
return genericHandler ?? null;
|
|
313
323
|
}
|
|
314
324
|
|
|
315
|
-
if (event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS)) {
|
|
325
|
+
if (event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS) || event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_DONE)) {
|
|
316
326
|
[event] = event.split("/");
|
|
317
327
|
}
|
|
318
328
|
|
|
@@ -334,11 +344,23 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
334
344
|
#buildDispatchData(payload, { key, queueEntries } = {}) {
|
|
335
345
|
const { useEventQueueUser } = this.eventConfig;
|
|
336
346
|
const userId = useEventQueueUser ? config.userId : payload.contextUser;
|
|
347
|
+
let triggerEvent;
|
|
348
|
+
|
|
349
|
+
if (payload.data?.triggerEvent) {
|
|
350
|
+
try {
|
|
351
|
+
triggerEvent = JSON.parse(payload.data.triggerEvent);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
this.logger.error("[saga] error parsing triggering event data", err);
|
|
354
|
+
} finally {
|
|
355
|
+
delete payload.data.triggerEvent;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
337
359
|
const req = payload._fromSend ? new cds.Request(payload) : new cds.Event(payload);
|
|
338
360
|
const invocationFn = payload._fromSend ? "send" : "emit";
|
|
339
361
|
delete req._fromSend;
|
|
340
362
|
delete req.contextUser;
|
|
341
|
-
req.eventQueue = { processor: this, key, queueEntries, payload };
|
|
363
|
+
req.eventQueue = { processor: this, key, queueEntries, payload, triggerEvent };
|
|
342
364
|
|
|
343
365
|
if (this.eventConfig.propagateContextProperties?.length && this.transactionMode === "isolated" && cds.context) {
|
|
344
366
|
for (const prop of this.eventConfig.propagateContextProperties) {
|
|
@@ -362,11 +384,11 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
362
384
|
}
|
|
363
385
|
|
|
364
386
|
async processEvent(processContext, key, queueEntries, payload) {
|
|
365
|
-
let statusTuple;
|
|
387
|
+
let statusTuple, result;
|
|
366
388
|
const { userId, invocationFn, req } = this.#buildDispatchData(payload, { key, queueEntries });
|
|
367
389
|
try {
|
|
368
390
|
await this.#setContextUser(processContext, userId, req);
|
|
369
|
-
|
|
391
|
+
result = await this.__srvUnboxed.tx(processContext)[invocationFn](req);
|
|
370
392
|
statusTuple = this.#determineResultStatus(result, queueEntries);
|
|
371
393
|
} catch (err) {
|
|
372
394
|
this.logger.error("error processing outboxed service call", err, {
|
|
@@ -381,19 +403,20 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
381
403
|
]);
|
|
382
404
|
}
|
|
383
405
|
|
|
384
|
-
await this.#publishFollowupEvents(processContext, req, statusTuple);
|
|
406
|
+
await this.#publishFollowupEvents(processContext, req, statusTuple, result);
|
|
385
407
|
return statusTuple;
|
|
386
408
|
}
|
|
387
409
|
|
|
388
|
-
async #publishFollowupEvents(processContext, req, statusTuple) {
|
|
410
|
+
async #publishFollowupEvents(processContext, req, statusTuple, triggerEventResult) {
|
|
389
411
|
const succeeded = this.#checkHandlerExists({ event: req.event, saga: EVENT_QUEUE_ACTIONS.SAGA_SUCCESS });
|
|
390
412
|
const failed = this.#checkHandlerExists({ event: req.event, saga: EVENT_QUEUE_ACTIONS.SAGA_FAILED });
|
|
413
|
+
const done = this.#checkHandlerExists({ event: req.event, saga: EVENT_QUEUE_ACTIONS.SAGA_DONE });
|
|
391
414
|
|
|
392
|
-
if (!succeeded && !failed) {
|
|
415
|
+
if (!succeeded && !failed && !done) {
|
|
393
416
|
return;
|
|
394
417
|
}
|
|
395
418
|
|
|
396
|
-
if (req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_FAILED)) {
|
|
419
|
+
if (req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_FAILED) || req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_DONE)) {
|
|
397
420
|
return;
|
|
398
421
|
}
|
|
399
422
|
|
|
@@ -405,6 +428,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
405
428
|
tx._eventQueue.events = [];
|
|
406
429
|
}
|
|
407
430
|
|
|
431
|
+
const queued = cds.queued(this.__srv);
|
|
408
432
|
for (const [, result] of statusTuple) {
|
|
409
433
|
const data = result.nextData ?? req.data;
|
|
410
434
|
if (
|
|
@@ -412,12 +436,41 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
412
436
|
result.status === EventProcessingStatus.Done &&
|
|
413
437
|
!req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS)
|
|
414
438
|
) {
|
|
415
|
-
|
|
439
|
+
if (statusTuple.length === 1 && req.eventQueue.queueEntries.length === 1) {
|
|
440
|
+
const triggerEventPropagate = { triggerEventResult };
|
|
441
|
+
const [triggerEvent] = req.eventQueue.queueEntries;
|
|
442
|
+
for (const propertyName of PROPAGATE_EVENT_QUEUE_ENTRIES) {
|
|
443
|
+
triggerEventPropagate[propertyName] = triggerEvent[propertyName];
|
|
444
|
+
}
|
|
445
|
+
data.triggerEvent = JSON.stringify(triggerEventPropagate);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await queued.tx(processContext).send(succeeded, data);
|
|
416
449
|
}
|
|
417
450
|
|
|
418
451
|
if (failed && result.status === EventProcessingStatus.Error) {
|
|
419
452
|
result.error && (data.error = this._error2String(result.error));
|
|
420
|
-
|
|
453
|
+
if (statusTuple.length === 1 && req.eventQueue.queueEntries.length === 1) {
|
|
454
|
+
const triggerEventPropagate = { triggerEventResult };
|
|
455
|
+
const [triggerEvent] = req.eventQueue.queueEntries;
|
|
456
|
+
for (const propertyName of PROPAGATE_EVENT_QUEUE_ENTRIES) {
|
|
457
|
+
triggerEventPropagate[propertyName] = triggerEvent[propertyName];
|
|
458
|
+
}
|
|
459
|
+
data.triggerEvent = JSON.stringify(triggerEventPropagate);
|
|
460
|
+
}
|
|
461
|
+
await queued.tx(processContext).send(failed, data);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (done && !req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS)) {
|
|
465
|
+
if (statusTuple.length === 1 && req.eventQueue.queueEntries.length === 1) {
|
|
466
|
+
const triggerEventPropagate = { triggerEventResult };
|
|
467
|
+
const [triggerEvent] = req.eventQueue.queueEntries;
|
|
468
|
+
for (const propertyName of PROPAGATE_EVENT_QUEUE_ENTRIES) {
|
|
469
|
+
triggerEventPropagate[propertyName] = triggerEvent[propertyName];
|
|
470
|
+
}
|
|
471
|
+
data.triggerEvent = JSON.stringify(triggerEventPropagate);
|
|
472
|
+
}
|
|
473
|
+
await queued.tx(processContext).send(done, data);
|
|
421
474
|
}
|
|
422
475
|
|
|
423
476
|
delete result.nextData;
|
|
@@ -426,7 +479,11 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
426
479
|
if (config.insertEventsBeforeCommit) {
|
|
427
480
|
this.nextSagaEvents = tx._eventQueue?.events;
|
|
428
481
|
} else {
|
|
429
|
-
|
|
482
|
+
const hasError = statusTuple.some(([, result]) => result.status === EventProcessingStatus.Error);
|
|
483
|
+
this.nextSagaEvents = tx._eventQueue?.events.filter((event) => {
|
|
484
|
+
const eventName = JSON.parse(event.payload).event;
|
|
485
|
+
return eventName === failed || (hasError && eventName === done);
|
|
486
|
+
});
|
|
430
487
|
}
|
|
431
488
|
|
|
432
489
|
if (tx._eventQueue) {
|
package/src/runner/openEvents.js
CHANGED
|
@@ -29,12 +29,12 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
|
|
|
29
29
|
new Date(startTime.getTime() - 30 * MS_IN_DAYS).toISOString(),
|
|
30
30
|
")"
|
|
31
31
|
)
|
|
32
|
-
.columns("type", "subType", "namespace")
|
|
32
|
+
.columns("count(ID) as count", "type", "subType", "namespace")
|
|
33
33
|
.groupBy("type", "subType", "namespace")
|
|
34
34
|
);
|
|
35
35
|
|
|
36
36
|
const result = [];
|
|
37
|
-
for (const { type, subType, namespace } of entries) {
|
|
37
|
+
for (const { type, subType, namespace, count } of entries) {
|
|
38
38
|
if (config.isCapOutboxEvent(type)) {
|
|
39
39
|
const { srvName, actionName } = config.normalizeSubType(type, subType);
|
|
40
40
|
try {
|
|
@@ -48,14 +48,14 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
|
|
|
48
48
|
config.addCAPServiceWithoutEnvConfig(subType, service);
|
|
49
49
|
}
|
|
50
50
|
if (config.shouldBeProcessedInThisApplication(type, subType, namespace)) {
|
|
51
|
-
result.push({ namespace, type, subType });
|
|
51
|
+
result.push({ namespace, type, subType, count });
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
} catch {
|
|
55
55
|
/* ignore catch */
|
|
56
56
|
} finally {
|
|
57
57
|
if (!filterAppSpecificEvents) {
|
|
58
|
-
result.push({ namespace, type, subType });
|
|
58
|
+
result.push({ namespace, type, subType, count });
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
} else {
|
|
@@ -64,10 +64,10 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
|
|
|
64
64
|
config.getEventConfig(type, subType, namespace) &&
|
|
65
65
|
config.shouldBeProcessedInThisApplication(type, subType, namespace)
|
|
66
66
|
) {
|
|
67
|
-
result.push({ namespace, type, subType });
|
|
67
|
+
result.push({ namespace, type, subType, count });
|
|
68
68
|
}
|
|
69
69
|
} else {
|
|
70
|
-
result.push({ namespace, type, subType });
|
|
70
|
+
result.push({ namespace, type, subType, count });
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
}
|
package/src/runner/runner.js
CHANGED
|
@@ -14,6 +14,7 @@ const common = require("../shared/common");
|
|
|
14
14
|
const config = require("../config");
|
|
15
15
|
const redisPub = require("../redis/redisPub");
|
|
16
16
|
const openEvents = require("./openEvents");
|
|
17
|
+
const eventQueueStats = require("../shared/eventQueueStats");
|
|
17
18
|
const { runEventCombinationForTenant } = require("./runnerHelper");
|
|
18
19
|
const { trace } = require("../shared/openTelemetry");
|
|
19
20
|
|
|
@@ -141,7 +142,7 @@ const _executeEventsAllTenantsRedis = async (tenantIds) => {
|
|
|
141
142
|
} catch (err) {
|
|
142
143
|
logger.error("executing event queue run for multi instance and tenant failed", err);
|
|
143
144
|
}
|
|
144
|
-
|
|
145
|
+
const tenantCounts = {};
|
|
145
146
|
for (const tenantId of tenantIds) {
|
|
146
147
|
try {
|
|
147
148
|
await cds.tx({ tenant: tenantId }, async (tx) => {
|
|
@@ -160,6 +161,18 @@ const _executeEventsAllTenantsRedis = async (tenantIds) => {
|
|
|
160
161
|
tenantId,
|
|
161
162
|
entries: entries.length,
|
|
162
163
|
});
|
|
164
|
+
tenantCounts[tenantId] = entries;
|
|
165
|
+
const pendingByNamespace = Object.fromEntries(config.processingNamespaces.map((name) => [name, 0]));
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
pendingByNamespace[entry.namespace] = (pendingByNamespace[entry.namespace] ?? 0) + entry.count;
|
|
168
|
+
}
|
|
169
|
+
if (config.collectEventQueueMetrics) {
|
|
170
|
+
for (const [namespace, count] of Object.entries(pendingByNamespace)) {
|
|
171
|
+
eventQueueStats
|
|
172
|
+
.setTenantCounter(tenantId, namespace, eventQueueStats.StatusField.Pending, count)
|
|
173
|
+
.catch((err) => logger.error("updating tenant stats failed", err, { tenantId, namespace }));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
163
176
|
if (!entries.length) {
|
|
164
177
|
return;
|
|
165
178
|
}
|
|
@@ -178,6 +191,19 @@ const _executeEventsAllTenantsRedis = async (tenantIds) => {
|
|
|
178
191
|
logger.error("broadcasting events for tenant failed", { tenantId }, err);
|
|
179
192
|
}
|
|
180
193
|
}
|
|
194
|
+
const globalPendingByNamespace = Object.fromEntries(config.processingNamespaces.map((namespace) => [namespace, 0]));
|
|
195
|
+
for (const tenantEntries of Object.values(tenantCounts)) {
|
|
196
|
+
for (const entry of tenantEntries) {
|
|
197
|
+
globalPendingByNamespace[entry.namespace] = (globalPendingByNamespace[entry.namespace] ?? 0) + entry.count;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (config.collectEventQueueMetrics) {
|
|
201
|
+
for (const [namespace, count] of Object.entries(globalPendingByNamespace)) {
|
|
202
|
+
eventQueueStats
|
|
203
|
+
.setGlobalCounter(namespace, eventQueueStats.StatusField.Pending, count)
|
|
204
|
+
.catch((err) => logger.error("updating global stats failed", err, { namespace }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
181
207
|
};
|
|
182
208
|
|
|
183
209
|
const _executeEventsAllTenants = async (tenantIds) => {
|
|
@@ -367,6 +393,17 @@ const _singleTenantRedis = async () => {
|
|
|
367
393
|
logger.info("broadcasting events for run", {
|
|
368
394
|
entries: entries.length,
|
|
369
395
|
});
|
|
396
|
+
const pendingByNamespace = Object.fromEntries(config.processingNamespaces.map((name) => [name, 0]));
|
|
397
|
+
for (const entry of entries) {
|
|
398
|
+
pendingByNamespace[entry.namespace] = (pendingByNamespace[entry.namespace] ?? 0) + entry.count;
|
|
399
|
+
}
|
|
400
|
+
if (config.collectEventQueueMetrics) {
|
|
401
|
+
for (const [namespace, count] of Object.entries(pendingByNamespace)) {
|
|
402
|
+
eventQueueStats
|
|
403
|
+
.setGlobalCounter(namespace, eventQueueStats.StatusField.Pending, count)
|
|
404
|
+
.catch((err) => logger.error("updating global stats failed", err, { namespace }));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
370
407
|
if (!entries.length) {
|
|
371
408
|
return;
|
|
372
409
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const cds = require("@sap/cds");
|
|
4
|
+
|
|
5
|
+
const redis = require("./redis");
|
|
6
|
+
const config = require("../config");
|
|
7
|
+
|
|
8
|
+
const COMPONENT_NAME = "/eventQueue/eventQueueStats";
|
|
9
|
+
|
|
10
|
+
const StatusField = {
|
|
11
|
+
Pending: "pending",
|
|
12
|
+
InProgress: "inProgress",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const _tenantKey = (tenantId) => `${config.redisNamespace(true)}##stats##tenant##${tenantId}`;
|
|
16
|
+
const _globalKey = () => `${config.redisNamespace(true)}##stats##global`;
|
|
17
|
+
const _keyPrefix = (namespace) => `${config.redisNamespace(false)}##${namespace}`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Atomically adjusts a tenant's event counter for the given status field.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} tenantId
|
|
23
|
+
* @param {string} field - one of StatusField.*
|
|
24
|
+
* @param {number} increment - positive to increment, negative to decrement
|
|
25
|
+
*/
|
|
26
|
+
const adjustTenantCounter = async (tenantId, field, increment) => {
|
|
27
|
+
try {
|
|
28
|
+
const client = await redis.createMainClientAndConnect();
|
|
29
|
+
await client.hIncrBy(_tenantKey(tenantId), field, increment);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
cds.log(COMPONENT_NAME).error("failed to adjust tenant stats counter", err, { tenantId, field, increment });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Atomically adjusts the global event counter for the given status field.
|
|
37
|
+
* Also updates the `updatedAt` timestamp on the global hash.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} field - one of StatusField.*
|
|
40
|
+
* @param {number} increment - positive to increment, negative to decrement
|
|
41
|
+
*/
|
|
42
|
+
const adjustGlobalCounter = async (field, increment) => {
|
|
43
|
+
try {
|
|
44
|
+
const client = await redis.createMainClientAndConnect();
|
|
45
|
+
await client.hIncrBy(_globalKey(), field, increment);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
cds.log(COMPONENT_NAME).error("failed to adjust global stats counter", err, { field, increment });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Increments a tenant counter and the matching global counter in a single call.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} tenantId
|
|
55
|
+
* @param {string} field - one of StatusField.*
|
|
56
|
+
* @param {number} [increment=1]
|
|
57
|
+
*/
|
|
58
|
+
const incrementCounters = async (tenantId, field, increment = 1) => {
|
|
59
|
+
await Promise.allSettled([adjustTenantCounter(tenantId, field, increment), adjustGlobalCounter(field, increment)]);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decrements a tenant counter and the matching global counter in a single call.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} tenantId
|
|
66
|
+
* @param {string} field - one of StatusField.*
|
|
67
|
+
* @param {number} [decrement=1]
|
|
68
|
+
*/
|
|
69
|
+
const decrementCounters = async (tenantId, field, decrement = 1) => {
|
|
70
|
+
await Promise.allSettled([adjustTenantCounter(tenantId, field, -decrement), adjustGlobalCounter(field, -decrement)]);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns the current stats hash for a single tenant.
|
|
75
|
+
* All counter values are returned as integers; missing fields default to 0.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} tenantId
|
|
78
|
+
* @returns {Promise<{pending: number, inProgress: number}>}
|
|
79
|
+
*/
|
|
80
|
+
const getTenantStats = async (tenantId) => {
|
|
81
|
+
try {
|
|
82
|
+
const client = await redis.createMainClientAndConnect();
|
|
83
|
+
const raw = await client.hGetAll(_tenantKey(tenantId));
|
|
84
|
+
return _parseCounterHash(raw);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
cds.log(COMPONENT_NAME).error("failed to read tenant stats", err, { tenantId });
|
|
87
|
+
return _emptyCounters();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the current global stats hash.
|
|
93
|
+
* All counter values are returned as integers; missing fields default to 0.
|
|
94
|
+
*
|
|
95
|
+
* @returns {Promise<{pending: number, inProgress: number}>}
|
|
96
|
+
*/
|
|
97
|
+
const getGlobalStats = async () => {
|
|
98
|
+
try {
|
|
99
|
+
const client = await redis.createMainClientAndConnect();
|
|
100
|
+
const raw = await client.hGetAll(_globalKey());
|
|
101
|
+
return _parseCounterHash(raw);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
cds.log(COMPONENT_NAME).error("failed to read global stats", err);
|
|
104
|
+
return _emptyCounters();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Deletes the stats hash for a specific tenant.
|
|
110
|
+
* Intended for use during tenant offboarding. It does not adjust the global stats still will be fixed with the next global run
|
|
111
|
+
*
|
|
112
|
+
* @param {string} tenantId
|
|
113
|
+
*/
|
|
114
|
+
const setTenantCounter = async (tenantId, namespace, field, value) => {
|
|
115
|
+
try {
|
|
116
|
+
const client = await redis.createMainClientAndConnect();
|
|
117
|
+
await client.hSet(`${_keyPrefix(namespace)}##stats##tenant##${tenantId}`, field, value);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
cds.log(COMPONENT_NAME).error("failed to set tenant stats counter", err, { tenantId, namespace, field, value });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const setGlobalCounter = async (namespace, field, value) => {
|
|
124
|
+
try {
|
|
125
|
+
const client = await redis.createMainClientAndConnect();
|
|
126
|
+
await client.hSet(`${_keyPrefix(namespace)}##stats##global`, field, value);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
cds.log(COMPONENT_NAME).error("failed to set global stats counter", err, { namespace, field, value });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const getAllNamespaceStats = async () => {
|
|
133
|
+
const namespaces = config.processingNamespaces;
|
|
134
|
+
const client = await redis.createMainClientAndConnect();
|
|
135
|
+
const results = await Promise.allSettled(
|
|
136
|
+
namespaces.map(async (namespace) => {
|
|
137
|
+
const raw = await client.hGetAll(`${_keyPrefix(namespace)}##stats##global`);
|
|
138
|
+
return { namespace, stats: _parseCounterHash(raw) };
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const result of results) {
|
|
143
|
+
if (result.status === "fulfilled") {
|
|
144
|
+
out[result.value.namespace] = result.value.stats;
|
|
145
|
+
} else {
|
|
146
|
+
cds.log(COMPONENT_NAME).error("failed to read namespace stats", result.reason);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const deleteTenantStats = async (tenantId) => {
|
|
153
|
+
try {
|
|
154
|
+
const client = await redis.createMainClientAndConnect();
|
|
155
|
+
await client.del(_tenantKey(tenantId));
|
|
156
|
+
} catch (err) {
|
|
157
|
+
cds.log(COMPONENT_NAME).error("failed to delete tenant stats", err, { tenantId });
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resets the inProgress counter to 0 for all processing namespaces (global + all tenants).
|
|
163
|
+
* Called on instance startup to clean up stale counts left by a previous crash.
|
|
164
|
+
*/
|
|
165
|
+
const resetInProgressCounters = async () => {
|
|
166
|
+
try {
|
|
167
|
+
const clientOrCluster = await redis.createMainClientAndConnect();
|
|
168
|
+
const clients = redis.isClusterMode() ? clientOrCluster.masters.map((master) => master.client) : [clientOrCluster];
|
|
169
|
+
|
|
170
|
+
const globalOps = config.processingNamespaces.map((namespace) =>
|
|
171
|
+
clientOrCluster.hSet(`${_keyPrefix(namespace)}##stats##global`, StatusField.InProgress, 0)
|
|
172
|
+
);
|
|
173
|
+
await Promise.allSettled(globalOps);
|
|
174
|
+
|
|
175
|
+
// NOTE: use SCAN because KEYS is not supported for cluster clients
|
|
176
|
+
for (const client of clients) {
|
|
177
|
+
for await (const key of client.scanIterator({ MATCH: "*##stats##tenant##*", COUNT: 1000 })) {
|
|
178
|
+
await client.hSet(key, StatusField.InProgress, 0);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
cds.log(COMPONENT_NAME).error("failed to reset inProgress counters on startup", err);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const _parseCounterHash = (raw) => ({
|
|
187
|
+
[StatusField.Pending]: raw[StatusField.Pending] != null ? parseInt(raw[StatusField.Pending]) : 0,
|
|
188
|
+
[StatusField.InProgress]: raw[StatusField.InProgress] != null ? parseInt(raw[StatusField.InProgress]) : 0,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const _emptyCounters = () => ({
|
|
192
|
+
[StatusField.Pending]: 0,
|
|
193
|
+
[StatusField.InProgress]: 0,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
StatusField,
|
|
198
|
+
incrementCounters,
|
|
199
|
+
decrementCounters,
|
|
200
|
+
adjustTenantCounter,
|
|
201
|
+
adjustGlobalCounter,
|
|
202
|
+
setTenantCounter,
|
|
203
|
+
setGlobalCounter,
|
|
204
|
+
getAllNamespaceStats,
|
|
205
|
+
getTenantStats,
|
|
206
|
+
getGlobalStats,
|
|
207
|
+
deleteTenantStats,
|
|
208
|
+
resetInProgressCounters,
|
|
209
|
+
};
|
|
@@ -12,9 +12,14 @@ const cds = require("@sap/cds");
|
|
|
12
12
|
const otel = _resilientRequire("@opentelemetry/api");
|
|
13
13
|
|
|
14
14
|
const config = require("../config");
|
|
15
|
+
const eventQueueStats = require("./eventQueueStats");
|
|
16
|
+
const { getEnvInstance } = require("./env");
|
|
15
17
|
|
|
16
18
|
const COMPONENT_NAME = "/shared/openTelemetry";
|
|
17
19
|
|
|
20
|
+
let _statsSnapshot = null;
|
|
21
|
+
let _metricsInitialized = false;
|
|
22
|
+
|
|
18
23
|
const trace = async (context, label, fn, { attributes = {}, newRootSpan = false, traceContext } = {}) => {
|
|
19
24
|
if (!config.enableTelemetry || !otel) {
|
|
20
25
|
return fn();
|
|
@@ -110,4 +115,77 @@ const getCurrentTraceContext = () => {
|
|
|
110
115
|
return carrier;
|
|
111
116
|
};
|
|
112
117
|
|
|
113
|
-
|
|
118
|
+
const _refreshStats = async () => {
|
|
119
|
+
try {
|
|
120
|
+
const namespaces = await eventQueueStats.getAllNamespaceStats();
|
|
121
|
+
_statsSnapshot = { namespaces, lastRefreshedAt: Date.now() };
|
|
122
|
+
} catch (err) {
|
|
123
|
+
cds.log(COMPONENT_NAME).error("failed to refresh queue stats for metrics", err);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const initMetrics = () => {
|
|
128
|
+
if (
|
|
129
|
+
_metricsInitialized ||
|
|
130
|
+
!config.collectEventQueueMetrics ||
|
|
131
|
+
!config.enableTelemetry ||
|
|
132
|
+
!config.redisEnabled ||
|
|
133
|
+
!config.registerAsEventProcessor ||
|
|
134
|
+
(getEnvInstance().applicationInstance !== undefined && getEnvInstance().applicationInstance !== 0) ||
|
|
135
|
+
!otel?.metrics
|
|
136
|
+
) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const meterProvider = otel.metrics.getMeterProvider?.();
|
|
140
|
+
if (!meterProvider) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_metricsInitialized = true;
|
|
145
|
+
|
|
146
|
+
eventQueueStats
|
|
147
|
+
.resetInProgressCounters()
|
|
148
|
+
.catch((err) => cds.log(COMPONENT_NAME).error("failed to reset inProgress counters", err));
|
|
149
|
+
|
|
150
|
+
const meter = otel.metrics.getMeter("@cap-js-community/event-queue");
|
|
151
|
+
|
|
152
|
+
const pendingGauge = meter.createObservableGauge("cap.event_queue.jobs.pending", {
|
|
153
|
+
description: "Current number of jobs waiting to be processed.",
|
|
154
|
+
unit: "1",
|
|
155
|
+
});
|
|
156
|
+
const inProgressGauge = meter.createObservableGauge("cap.event_queue.jobs.in_progress", {
|
|
157
|
+
description: "Current number of jobs actively being processed by workers.",
|
|
158
|
+
unit: "1",
|
|
159
|
+
});
|
|
160
|
+
const refreshAgeGauge = meter.createObservableGauge("cap.event_queue.stats.refresh_age", {
|
|
161
|
+
description: "Age of the most recent queue statistics snapshot.",
|
|
162
|
+
unit: "s",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
_statsSnapshot = {
|
|
166
|
+
lastRefreshedAt: Date.now(),
|
|
167
|
+
namespaces: Object.fromEntries(
|
|
168
|
+
config.processingNamespaces.map((namespace) => [namespace, { pending: 0, inProgress: 0 }])
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
_refreshStats();
|
|
172
|
+
|
|
173
|
+
meter.addBatchObservableCallback(
|
|
174
|
+
(observableResult) => {
|
|
175
|
+
if (!_statsSnapshot) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
observableResult.observe(refreshAgeGauge, (Date.now() - _statsSnapshot.lastRefreshedAt) / 1000);
|
|
179
|
+
for (const [namespace, stats] of Object.entries(_statsSnapshot.namespaces)) {
|
|
180
|
+
const attrs = { "queue.namespace": namespace };
|
|
181
|
+
observableResult.observe(pendingGauge, stats.pending, attrs);
|
|
182
|
+
observableResult.observe(inProgressGauge, stats.inProgress, attrs);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
[pendingGauge, inProgressGauge, refreshAgeGauge]
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
setInterval(_refreshStats, 30_000).unref();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
module.exports = { trace, getCurrentTraceContext, initMetrics };
|