@cap-js-community/event-queue 2.1.0-beta.3 → 2.1.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/db/Event.cds +1 -1
- package/package.json +19 -20
- package/src/EventQueueError.js +15 -0
- package/src/EventQueueProcessorBase.js +5 -4
- package/src/config.js +39 -18
- package/src/dbHandler.js +105 -24
- package/src/initialize.js +6 -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/SetIntervalDriftSafe.js +0 -6
- package/src/shared/common.js +4 -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.
|
|
3
|
+
"version": "2.1.1",
|
|
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",
|
|
@@ -41,30 +41,30 @@
|
|
|
41
41
|
"upgrade-lock": "npx shx rm -rf package-lock.json node_modules && npm i --package-lock"
|
|
42
42
|
},
|
|
43
43
|
"engines": {
|
|
44
|
-
"node": ">=
|
|
44
|
+
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
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.4"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@
|
|
55
|
-
"@cap-js/
|
|
56
|
-
"@cap-js/
|
|
57
|
-
"@cap-js/
|
|
58
|
-
"@
|
|
59
|
-
"@opentelemetry/api": "^1.9.
|
|
60
|
-
"@sap/cds": "^9.
|
|
61
|
-
"@sap/cds-dk": "^9.
|
|
62
|
-
"eslint": "^
|
|
54
|
+
"@cap-js/cds-test": "^1.0.1",
|
|
55
|
+
"@cap-js/db-service": "^2.11.0",
|
|
56
|
+
"@cap-js/hana": "^2.8.0",
|
|
57
|
+
"@cap-js/sqlite": "^2.4.0",
|
|
58
|
+
"@eslint/js": "^10.0.1",
|
|
59
|
+
"@opentelemetry/api": "^1.9.1",
|
|
60
|
+
"@sap/cds": "^9.9.1",
|
|
61
|
+
"@sap/cds-dk": "^9.9.0",
|
|
62
|
+
"eslint": "^10.3.0",
|
|
63
63
|
"eslint-config-prettier": "^10.1.8",
|
|
64
|
-
"eslint-plugin-jest": "^29.
|
|
65
|
-
"
|
|
66
|
-
"jest": "^
|
|
67
|
-
"prettier": "^
|
|
64
|
+
"eslint-plugin-jest": "^29.15.2",
|
|
65
|
+
"globals": "^17.6.0",
|
|
66
|
+
"jest": "^30.3.0",
|
|
67
|
+
"prettier": "^3.8.3"
|
|
68
68
|
},
|
|
69
69
|
"homepage": "https://cap-js-community.github.io/event-queue/",
|
|
70
70
|
"repository": {
|
|
@@ -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/EventQueueError.js
CHANGED
|
@@ -28,6 +28,7 @@ const ERROR_CODES = {
|
|
|
28
28
|
APP_INSTANCES_FORMAT: "APP_INSTANCES_FORMAT",
|
|
29
29
|
MULTI_INSTANCE_PROCESSING_NOT_ALLOWED: "MULTI_INSTANCE_PROCESSING_NOT_ALLOWED",
|
|
30
30
|
INVALID_CLUSTER_HANDLER_RESULT: "INVALID_CLUSTER_HANDLER_RESULT",
|
|
31
|
+
INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT: "INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT",
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
const ERROR_CODES_META = {
|
|
@@ -107,6 +108,9 @@ const ERROR_CODES_META = {
|
|
|
107
108
|
[ERROR_CODES.MULTI_INSTANCE_PROCESSING_NOT_ALLOWED]: {
|
|
108
109
|
message: "The config multiInstanceProcessing is currently only allowed for ad-hoc events and single-tenant-apps.",
|
|
109
110
|
},
|
|
111
|
+
[ERROR_CODES.INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT]: {
|
|
112
|
+
message: "authCacheExpiryReductionMaxPercent must be a number between 0 and 80.",
|
|
113
|
+
},
|
|
110
114
|
};
|
|
111
115
|
|
|
112
116
|
class EventQueueError extends VError {
|
|
@@ -375,6 +379,17 @@ class EventQueueError extends VError {
|
|
|
375
379
|
);
|
|
376
380
|
}
|
|
377
381
|
|
|
382
|
+
static invalidAuthCacheExpiryReductionMaxPercent(value) {
|
|
383
|
+
const { message } = ERROR_CODES_META[ERROR_CODES.INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT];
|
|
384
|
+
return new EventQueueError(
|
|
385
|
+
{
|
|
386
|
+
name: ERROR_CODES.INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT,
|
|
387
|
+
info: { value },
|
|
388
|
+
},
|
|
389
|
+
message
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
378
393
|
static invalidClusterHandlerResult(clusterKey, propertyName) {
|
|
379
394
|
const { message } = ERROR_CODES_META[ERROR_CODES.INVALID_CLUSTER_HANDLER_RESULT];
|
|
380
395
|
return new EventQueueError(
|
|
@@ -69,7 +69,7 @@ class EventQueueProcessorBase {
|
|
|
69
69
|
this.__parallelEventProcessing = LIMIT_PARALLEL_EVENT_PROCESSING;
|
|
70
70
|
}
|
|
71
71
|
this.__concurrentEventProcessing = this.#eventConfig.multiInstanceProcessing;
|
|
72
|
-
this.__retryAttempts = this.#isPeriodic ? 1 : this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS;
|
|
72
|
+
this.__retryAttempts = this.#isPeriodic ? 1 : (this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS);
|
|
73
73
|
this.__selectMaxChunkSize = this.#eventConfig.selectMaxChunkSize ?? SELECT_LIMIT_EVENTS_PER_TICK;
|
|
74
74
|
this.__selectNextChunk = !!this.#eventConfig.checkForNextChunk;
|
|
75
75
|
this.__transactionMode = this.#eventConfig.transactionMode ?? TransactionMode.isolated;
|
|
@@ -457,7 +457,8 @@ class EventQueueProcessorBase {
|
|
|
457
457
|
eventSubType: this.#eventSubType,
|
|
458
458
|
statusMap,
|
|
459
459
|
});
|
|
460
|
-
const
|
|
460
|
+
const tsMs = Date.now();
|
|
461
|
+
const ts = new Date(tsMs).toISOString();
|
|
461
462
|
const updateData = Object.entries(statusMap).reduce((result, [id, data]) => {
|
|
462
463
|
const key = this.allowedFieldsEventHandler
|
|
463
464
|
.map((name) => [name, data[name]])
|
|
@@ -503,7 +504,7 @@ class EventQueueProcessorBase {
|
|
|
503
504
|
|
|
504
505
|
if (!data.startAfter && [EventProcessingStatus.Error, EventProcessingStatus.Open].includes(data.status)) {
|
|
505
506
|
data.startAfter = new Date(
|
|
506
|
-
|
|
507
|
+
tsMs +
|
|
507
508
|
(data.status === EventProcessingStatus.Error
|
|
508
509
|
? this.#eventConfig.retryFailedAfter
|
|
509
510
|
: this.#eventConfig.retryOpenAfter)
|
|
@@ -954,7 +955,7 @@ class EventQueueProcessorBase {
|
|
|
954
955
|
* the function 'addEventWithPayloadForProcessing'. This function is called after the clustering and before the
|
|
955
956
|
* process-events-steps. The event data is available with this.eventProcessingMap.
|
|
956
957
|
*/
|
|
957
|
-
|
|
958
|
+
|
|
958
959
|
async beforeProcessingEvents() {}
|
|
959
960
|
|
|
960
961
|
async isOutdatedAndKeepAlive() {
|
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",
|
|
@@ -89,8 +90,6 @@ class Config {
|
|
|
89
90
|
#redisEnabled;
|
|
90
91
|
#initialized;
|
|
91
92
|
#instanceLoadLimit;
|
|
92
|
-
#tableNameEventQueue;
|
|
93
|
-
#tableNameEventLock;
|
|
94
93
|
#isEventQueueActive;
|
|
95
94
|
#configFilePath;
|
|
96
95
|
#processEventsAfterPublish;
|
|
@@ -107,6 +106,7 @@ class Config {
|
|
|
107
106
|
#redisOptions;
|
|
108
107
|
#insertEventsBeforeCommit;
|
|
109
108
|
#enableTelemetry;
|
|
109
|
+
#collectEventQueueMetrics;
|
|
110
110
|
#unsubscribeHandlers = [];
|
|
111
111
|
#unsubscribedTenants = {};
|
|
112
112
|
#cronTimezone;
|
|
@@ -120,6 +120,7 @@ class Config {
|
|
|
120
120
|
#disableProcessingOfSuspendedTenants;
|
|
121
121
|
#namespace;
|
|
122
122
|
#processingNamespaces;
|
|
123
|
+
#authCacheExpiryReductionMaxPercent;
|
|
123
124
|
static #instance;
|
|
124
125
|
constructor() {
|
|
125
126
|
this.#logger = cds.log(COMPONENT_NAME);
|
|
@@ -130,12 +131,11 @@ class Config {
|
|
|
130
131
|
this.#redisEnabled = null;
|
|
131
132
|
this.#initialized = false;
|
|
132
133
|
this.#instanceLoadLimit = 100;
|
|
133
|
-
this.#tableNameEventQueue = null;
|
|
134
|
-
this.#tableNameEventLock = null;
|
|
135
134
|
this.#isEventQueueActive = true;
|
|
136
135
|
this.#configFilePath = null;
|
|
137
136
|
this.#processEventsAfterPublish = null;
|
|
138
137
|
this.#disableRedis = null;
|
|
138
|
+
this.#authCacheExpiryReductionMaxPercent = 10;
|
|
139
139
|
this.#env = getEnvInstance();
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -335,6 +335,7 @@ class Config {
|
|
|
335
335
|
(typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
|
|
336
336
|
(typeof cds.requires.queue === "object" && cds.requires.queue) || {},
|
|
337
337
|
(typeof cds.env.requires[serviceName]?.outbox === "object" && cds.env.requires[serviceName].outbox) || {},
|
|
338
|
+
(typeof cds.env.requires[serviceName]?.outboxed === "object" && cds.env.requires[serviceName].outboxed) || {},
|
|
338
339
|
(typeof cds.env.requires[serviceName]?.queued === "object" && cds.env.requires[serviceName].queued) || {}
|
|
339
340
|
);
|
|
340
341
|
}
|
|
@@ -386,19 +387,20 @@ class Config {
|
|
|
386
387
|
result.adHoc
|
|
387
388
|
);
|
|
388
389
|
result.adHoc[key] = specificEventConfig;
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
390
|
+
for (const sagaSuffix of [SAGA_SUCCESS, SAGA_DONE, SAGA_FAILED]) {
|
|
391
|
+
if (config.events[sagaSuffix]) {
|
|
392
|
+
const [adHocKey, sagaSpecificEventConfig] = this.addCAPOutboxEventSpecificAction(
|
|
393
|
+
srvConfig,
|
|
394
|
+
name,
|
|
395
|
+
fnName,
|
|
396
|
+
result.adHoc
|
|
397
|
+
);
|
|
398
|
+
result.adHoc[adHocKey] = sagaSpecificEventConfig;
|
|
399
|
+
} else {
|
|
400
|
+
const sagaConfig = { ...specificEventConfig };
|
|
401
|
+
sagaConfig.subType = [sagaConfig.subType, sagaSuffix].join("/");
|
|
402
|
+
result.adHoc[[key, sagaSuffix].join("/")] = sagaConfig;
|
|
403
|
+
}
|
|
402
404
|
}
|
|
403
405
|
}
|
|
404
406
|
}
|
|
@@ -433,7 +435,7 @@ class Config {
|
|
|
433
435
|
}
|
|
434
436
|
|
|
435
437
|
const [withoutSaga, sagaSuffix] = action.split("/");
|
|
436
|
-
if ([SAGA_FAILED, SAGA_SUCCESS].includes(sagaSuffix)) {
|
|
438
|
+
if ([SAGA_FAILED, SAGA_SUCCESS, SAGA_DONE].includes(sagaSuffix)) {
|
|
437
439
|
if (config?.events?.[withoutSaga]) {
|
|
438
440
|
return this.#mixCAPPropertyNamesWithEventQueueNames(config.events[withoutSaga]);
|
|
439
441
|
}
|
|
@@ -796,6 +798,17 @@ class Config {
|
|
|
796
798
|
return this.#configFilePath;
|
|
797
799
|
}
|
|
798
800
|
|
|
801
|
+
set authCacheExpiryReductionMaxPercent(value) {
|
|
802
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 80) {
|
|
803
|
+
throw EventQueueError.invalidAuthCacheExpiryReductionMaxPercent(value);
|
|
804
|
+
}
|
|
805
|
+
this.#authCacheExpiryReductionMaxPercent = value;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
get authCacheExpiryReductionMaxPercent() {
|
|
809
|
+
return this.#authCacheExpiryReductionMaxPercent;
|
|
810
|
+
}
|
|
811
|
+
|
|
799
812
|
set processEventsAfterPublish(value) {
|
|
800
813
|
this.#processEventsAfterPublish = value;
|
|
801
814
|
}
|
|
@@ -901,6 +914,14 @@ class Config {
|
|
|
901
914
|
return this.#enableTelemetry;
|
|
902
915
|
}
|
|
903
916
|
|
|
917
|
+
set collectEventQueueMetrics(value) {
|
|
918
|
+
this.#collectEventQueueMetrics = value;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
get collectEventQueueMetrics() {
|
|
922
|
+
return this.#collectEventQueueMetrics;
|
|
923
|
+
}
|
|
924
|
+
|
|
904
925
|
get isMultiTenancy() {
|
|
905
926
|
return !!cds.requires.multitenancy;
|
|
906
927
|
}
|
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,8 @@ const CONFIG_VARS = [
|
|
|
49
50
|
["disableProcessingOfSuspendedTenants", true],
|
|
50
51
|
["namespace", "default"],
|
|
51
52
|
["processingNamespaces", ["default"]],
|
|
53
|
+
["collectEventQueueMetrics", false],
|
|
54
|
+
["authCacheExpiryReductionMaxPercent", 10],
|
|
52
55
|
];
|
|
53
56
|
|
|
54
57
|
/**
|
|
@@ -78,6 +81,8 @@ const CONFIG_VARS = [
|
|
|
78
81
|
* @param {string} [options.crashOnRedisUnavailable=true] - If enabled an error is thrown if the redis connection check is not successful
|
|
79
82
|
* @param {string} [options.namespace=default] - Default namespace in which events are published
|
|
80
83
|
* @param {string} [options.processingNamespaces=[default]] - Namespaces which the application processes
|
|
84
|
+
* @param {boolean} [options.collectEventQueueMetrics=false] - Enable collection of event queue metrics (pending/inProgress counters) stored in Redis and exposed via OpenTelemetry gauges.
|
|
85
|
+
* @param {number} [options.authCacheExpiryReductionMaxPercent=10] - Maximum percentage (allowed range 0-80) by which the XSUAA token TTL is shortened when stored in the authContext cache. The actual reduction is randomized per token fetch in `[0, value]` so multi-tenant deployments do not all expire their cached tokens at the same time and stampede the XSUAA token endpoint. Values outside the allowed range throw on initialization.
|
|
81
86
|
*/
|
|
82
87
|
const initialize = async (options = {}) => {
|
|
83
88
|
if (config.initialized) {
|
|
@@ -125,6 +130,7 @@ const initialize = async (options = {}) => {
|
|
|
125
130
|
runInterval: config.runInterval,
|
|
126
131
|
useAsCAPQueue: config.useAsCAPQueue,
|
|
127
132
|
});
|
|
133
|
+
initMetrics();
|
|
128
134
|
resolveFn();
|
|
129
135
|
};
|
|
130
136
|
|
|
@@ -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
|
}
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const COMPONENT = "eventQueue/SetIntervalDriftSafe";
|
|
4
|
-
|
|
5
3
|
class SetIntervalDriftSafe {
|
|
6
4
|
#adjustedInterval;
|
|
7
5
|
#interval;
|
|
8
6
|
#expectedCycleTime = 0;
|
|
9
|
-
#nextTickScheduledFor;
|
|
10
|
-
#logger;
|
|
11
7
|
#shouldRun = true;
|
|
12
8
|
#lastTimeoutId;
|
|
13
9
|
|
|
14
10
|
constructor(interval) {
|
|
15
11
|
this.#interval = interval;
|
|
16
12
|
this.#adjustedInterval = interval;
|
|
17
|
-
this.#logger = cds.log(COMPONENT);
|
|
18
13
|
}
|
|
19
14
|
|
|
20
15
|
start(fn) {
|
|
@@ -34,7 +29,6 @@ class SetIntervalDriftSafe {
|
|
|
34
29
|
this.#adjustedInterval = this.#interval - (now - this.#expectedCycleTime);
|
|
35
30
|
this.#expectedCycleTime += this.#interval;
|
|
36
31
|
}
|
|
37
|
-
this.#nextTickScheduledFor = now + this.#adjustedInterval;
|
|
38
32
|
const timeoutId = setTimeout(() => {
|
|
39
33
|
if (!this.#shouldRun) {
|
|
40
34
|
return;
|
package/src/shared/common.js
CHANGED
|
@@ -99,7 +99,10 @@ const _getNewAuthContext = async (tenantId) => {
|
|
|
99
99
|
const token = await authService.fetchClientCredentialsToken({ zid: tenantId });
|
|
100
100
|
const tokenInfo = new xssec.XsuaaToken(token.access_token);
|
|
101
101
|
const authInfo = new xssec.XsuaaSecurityContext(authService, tokenInfo);
|
|
102
|
-
|
|
102
|
+
const ttl = tokenInfo.getExpirationDate().getTime() - Date.now();
|
|
103
|
+
// Randomized to avoid synchronized cache expiry across tenants stampeding XSUAA on token refresh.
|
|
104
|
+
const reductionPercent = Math.random() * config.authCacheExpiryReductionMaxPercent;
|
|
105
|
+
return [ttl * (1 - reductionPercent / 100), [null, authInfo]];
|
|
103
106
|
} catch (err) {
|
|
104
107
|
cds.log(COMPONENT_NAME).warn("failed to request authContext", {
|
|
105
108
|
err: err.message,
|
|
@@ -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 };
|