@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 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: UUID;
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-beta.3",
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": ">=20"
44
+ "node": "^20.19.0 || ^22.13.0 || >=24"
45
45
  },
46
46
  "dependencies": {
47
- "@cap-js-community/common": "^0.3.4",
48
- "@sap/xssec": "^4.12.2",
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.2"
51
+ "yaml": "^2.8.4"
52
52
  },
53
53
  "devDependencies": {
54
- "@actions/core": "^2.0.2",
55
- "@cap-js/cds-test": "^0.4.1",
56
- "@cap-js/db-service": "^2.8.2",
57
- "@cap-js/hana": "^2.6.0",
58
- "@cap-js/sqlite": "^2.1.3",
59
- "@opentelemetry/api": "^1.9.0",
60
- "@sap/cds": "^9.7.0",
61
- "@sap/cds-dk": "^9.7.0",
62
- "eslint": "^8.57.1",
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.12.1",
65
- "eslint-plugin-node": "^11.1.0",
66
- "jest": "^29.7.0",
67
- "prettier": "^2.8.8"
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": {
@@ -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 ts = new Date().toISOString();
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
- Date.now() +
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
- // eslint-disable-next-line no-unused-vars
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 sagaSuccessKey = [fnName, SAGA_SUCCESS].join("/");
390
- if (config.events[sagaSuccessKey]) {
391
- const [sagaKey, sagaSpecificEventConfig] = this.addCAPOutboxEventSpecificAction(
392
- srvConfig,
393
- name,
394
- fnName,
395
- result.adHoc
396
- );
397
- result.adHoc[sagaKey] = sagaSpecificEventConfig;
398
- } else {
399
- const sagaConfig = { ...specificEventConfig };
400
- sagaConfig.subType = [sagaConfig.subType, SAGA_SUCCESS].join("/");
401
- result.adHoc[[key, SAGA_SUCCESS].join("/")] = sagaConfig;
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
- const eventCombinations = Object.keys(
30
- data.reduce((result, event) => {
31
- const key = [event.type, event.subType, event.namespace].join("##");
32
- if (
33
- !config.hasEventAfterCommitFlag(event.type, event.subType, event.namespace) ||
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[key] = true;
40
- return result;
41
- }, {})
42
- );
38
+ result.push(key);
39
+ }
40
+ return result;
41
+ }, []);
43
42
 
44
- eventCombinations.length &&
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
- const events = eventCombinations.map((eventCombination) => {
47
- const [type, subType, namespace] = eventCombination.split("##");
48
- return { type, subType, namespace };
49
- });
50
-
51
- redisPub.broadcastEvent(req.tenant, events).catch((err) => {
52
- cds.log(COMPONENT_NAME).error("db handler failure during broadcasting event", err, {
53
- tenant: req.tenant,
54
- events,
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
- const result = await this.__srvUnboxed.tx(processContext)[invocationFn](req);
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
- await this.__srv.tx(processContext).send(succeeded, data);
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
- await this.__srv.tx(processContext).send(failed, data);
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
- this.nextSagaEvents = tx._eventQueue?.events.filter((event) => JSON.parse(event.payload).event === failed);
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) {
@@ -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
  }
@@ -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;
@@ -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
- return [tokenInfo.getExpirationDate().getTime() - Date.now(), [null, authInfo]];
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
- module.exports = { trace, getCurrentTraceContext };
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 };