@cap-js-community/event-queue 2.1.0-beta.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/db/Event.cds 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;
@@ -24,6 +24,6 @@ entity Event: cuid {
24
24
  createdAt: Timestamp @cds.on.insert : $now;
25
25
  startAfter: Timestamp;
26
26
  context: LargeString;
27
- error: String;
27
+ error: LargeString;
28
28
  namespace: String default 'default';
29
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "2.1.0-beta.2",
3
+ "version": "2.1.0",
4
4
  "description": "An event queue that enables secure transactional processing of asynchronous and periodic events, featuring instant event processing with Redis Pub/Sub and load distribution across all application instances.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -44,11 +44,11 @@
44
44
  "node": ">=20"
45
45
  },
46
46
  "dependencies": {
47
- "@cap-js-community/common": "^0.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.3"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@actions/core": "^2.0.2",
@@ -56,9 +56,9 @@
56
56
  "@cap-js/db-service": "^2.8.2",
57
57
  "@cap-js/hana": "^2.6.0",
58
58
  "@cap-js/sqlite": "^2.1.3",
59
- "@opentelemetry/api": "^1.9.0",
60
- "@sap/cds": "^9.7.0",
61
- "@sap/cds-dk": "^9.7.0",
59
+ "@opentelemetry/api": "^1.9.1",
60
+ "@sap/cds": "^9.8.4",
61
+ "@sap/cds-dk": "^9.8.2",
62
62
  "eslint": "^8.57.1",
63
63
  "eslint-config-prettier": "^10.1.8",
64
64
  "eslint-plugin-jest": "^29.12.1",
@@ -100,8 +100,7 @@
100
100
  "requires": {
101
101
  "xsuaa-eventQueue": {
102
102
  "vcap": {
103
- "label": "xsuaa",
104
- "plan": "application"
103
+ "label": "xsuaa"
105
104
  }
106
105
  },
107
106
  "redis-eventQueue": {
package/src/config.js CHANGED
@@ -30,6 +30,7 @@ const UTC_DEFAULT = false;
30
30
  const USE_CRON_TZ_DEFAULT = true;
31
31
  const SAGA_SUCCESS = "#succeeded";
32
32
  const SAGA_FAILED = "#failed";
33
+ const SAGA_DONE = "#done";
33
34
 
34
35
  const BASE_TABLES = {
35
36
  EVENT: "sap.eventqueue.Event",
@@ -107,6 +108,7 @@ class Config {
107
108
  #redisOptions;
108
109
  #insertEventsBeforeCommit;
109
110
  #enableTelemetry;
111
+ #collectEventQueueMetrics;
110
112
  #unsubscribeHandlers = [];
111
113
  #unsubscribedTenants = {};
112
114
  #cronTimezone;
@@ -386,19 +388,20 @@ class Config {
386
388
  result.adHoc
387
389
  );
388
390
  result.adHoc[key] = specificEventConfig;
389
- const 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;
391
+ for (const sagaSuffix of [SAGA_SUCCESS, SAGA_DONE, SAGA_FAILED]) {
392
+ if (config.events[sagaSuffix]) {
393
+ const [adHocKey, sagaSpecificEventConfig] = this.addCAPOutboxEventSpecificAction(
394
+ srvConfig,
395
+ name,
396
+ fnName,
397
+ result.adHoc
398
+ );
399
+ result.adHoc[adHocKey] = sagaSpecificEventConfig;
400
+ } else {
401
+ const sagaConfig = { ...specificEventConfig };
402
+ sagaConfig.subType = [sagaConfig.subType, sagaSuffix].join("/");
403
+ result.adHoc[[key, sagaSuffix].join("/")] = sagaConfig;
404
+ }
402
405
  }
403
406
  }
404
407
  }
@@ -433,7 +436,7 @@ class Config {
433
436
  }
434
437
 
435
438
  const [withoutSaga, sagaSuffix] = action.split("/");
436
- if ([SAGA_FAILED, SAGA_SUCCESS].includes(sagaSuffix)) {
439
+ if ([SAGA_FAILED, SAGA_SUCCESS, SAGA_DONE].includes(sagaSuffix)) {
437
440
  if (config?.events?.[withoutSaga]) {
438
441
  return this.#mixCAPPropertyNamesWithEventQueueNames(config.events[withoutSaga]);
439
442
  }
@@ -901,6 +904,14 @@ class Config {
901
904
  return this.#enableTelemetry;
902
905
  }
903
906
 
907
+ set collectEventQueueMetrics(value) {
908
+ this.#collectEventQueueMetrics = value;
909
+ }
910
+
911
+ get collectEventQueueMetrics() {
912
+ return this.#collectEventQueueMetrics;
913
+ }
914
+
904
915
  get isMultiTenancy() {
905
916
  return !!cds.requires.multitenancy;
906
917
  }
package/src/dbHandler.js CHANGED
@@ -4,11 +4,14 @@ const cds = require("@sap/cds");
4
4
 
5
5
  const redisPub = require("./redis/redisPub");
6
6
  const config = require("./config");
7
+ const eventQueueStats = require("./shared/eventQueueStats");
8
+ const { EventProcessingStatus } = require("./constants");
7
9
 
8
10
  const COMPONENT_NAME = "/eventQueue/dbHandler";
9
11
  const registeredHandlers = {
10
12
  eventQueueDbHandler: false,
11
13
  beforeDbHandler: false,
14
+ updateDbHandler: false,
12
15
  };
13
16
 
14
17
  const registerEventQueueDbHandler = (dbService) => {
@@ -26,36 +29,114 @@ const registerEventQueueDbHandler = (dbService) => {
26
29
  req.tx._.eventQueuePublishEvents = req.tx._.eventQueuePublishEvents ?? {};
27
30
  const eventQueuePublishEvents = req.tx._.eventQueuePublishEvents;
28
31
  const data = Array.isArray(req.query.INSERT.entries) ? req.query.INSERT.entries : [req.query.INSERT.entries];
29
- 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,7 @@ const CONFIG_VARS = [
49
50
  ["disableProcessingOfSuspendedTenants", true],
50
51
  ["namespace", "default"],
51
52
  ["processingNamespaces", ["default"]],
53
+ ["collectEventQueueMetrics", false],
52
54
  ];
53
55
 
54
56
  /**
@@ -78,6 +80,7 @@ const CONFIG_VARS = [
78
80
  * @param {string} [options.crashOnRedisUnavailable=true] - If enabled an error is thrown if the redis connection check is not successful
79
81
  * @param {string} [options.namespace=default] - Default namespace in which events are published
80
82
  * @param {string} [options.processingNamespaces=[default]] - Namespaces which the application processes
83
+ * @param {boolean} [options.collectEventQueueMetrics=false] - Enable collection of event queue metrics (pending/inProgress counters) stored in Redis and exposed via OpenTelemetry gauges.
81
84
  */
82
85
  const initialize = async (options = {}) => {
83
86
  if (config.initialized) {
@@ -125,6 +128,7 @@ const initialize = async (options = {}) => {
125
128
  runInterval: config.runInterval,
126
129
  useAsCAPQueue: config.useAsCAPQueue,
127
130
  });
131
+ initMetrics();
128
132
  resolveFn();
129
133
  };
130
134
 
@@ -16,8 +16,18 @@ const EVENT_QUEUE_ACTIONS = {
16
16
  CHECK_AND_ADJUST: "eventQueueCheckAndAdjustPayload",
17
17
  SAGA_SUCCESS: "#succeeded",
18
18
  SAGA_FAILED: "#failed",
19
+ SAGA_DONE: "#done",
19
20
  };
20
21
 
22
+ const PROPAGATE_EVENT_QUEUE_ENTRIES = [
23
+ "ID",
24
+ "lastAttempTimestamp",
25
+ "payload",
26
+ "referenceEntity",
27
+ "referenceEntityKey",
28
+ "status",
29
+ ];
30
+
21
31
  class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
22
32
  constructor(context, eventType, eventSubType, config) {
23
33
  super(context, eventType, eventSubType, config);
@@ -312,7 +322,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
312
322
  return genericHandler ?? null;
313
323
  }
314
324
 
315
- if (event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS)) {
325
+ if (event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS) || event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_DONE)) {
316
326
  [event] = event.split("/");
317
327
  }
318
328
 
@@ -334,11 +344,23 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
334
344
  #buildDispatchData(payload, { key, queueEntries } = {}) {
335
345
  const { useEventQueueUser } = this.eventConfig;
336
346
  const userId = useEventQueueUser ? config.userId : payload.contextUser;
347
+ let triggerEvent;
348
+
349
+ if (payload.data?.triggerEvent) {
350
+ try {
351
+ triggerEvent = JSON.parse(payload.data.triggerEvent);
352
+ } catch (err) {
353
+ this.logger.error("[saga] error parsing triggering event data", err);
354
+ } finally {
355
+ delete payload.data.triggerEvent;
356
+ }
357
+ }
358
+
337
359
  const req = payload._fromSend ? new cds.Request(payload) : new cds.Event(payload);
338
360
  const invocationFn = payload._fromSend ? "send" : "emit";
339
361
  delete req._fromSend;
340
362
  delete req.contextUser;
341
- req.eventQueue = { processor: this, key, queueEntries, payload };
363
+ req.eventQueue = { processor: this, key, queueEntries, payload, triggerEvent };
342
364
 
343
365
  if (this.eventConfig.propagateContextProperties?.length && this.transactionMode === "isolated" && cds.context) {
344
366
  for (const prop of this.eventConfig.propagateContextProperties) {
@@ -362,11 +384,11 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
362
384
  }
363
385
 
364
386
  async processEvent(processContext, key, queueEntries, payload) {
365
- let statusTuple;
387
+ let statusTuple, result;
366
388
  const { userId, invocationFn, req } = this.#buildDispatchData(payload, { key, queueEntries });
367
389
  try {
368
390
  await this.#setContextUser(processContext, userId, req);
369
- 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) {
@@ -11,6 +11,8 @@ const CDS_EVENT_TYPE = "CAP_OUTBOX";
11
11
  const COMPONENT_NAME = "/eventQueue/eventQueueAsOutbox";
12
12
  const EVENT_QUEUE_SPECIFIC_FIELDS = ["startAfter", "referenceEntity", "referenceEntityKey", "namespace"];
13
13
 
14
+ const TO_COPY = ["inbound", "event", "data", "queue", "results", "method", "path", "params", "entity", "service"];
15
+
14
16
  function outboxed(srv, customOpts) {
15
17
  if (!(new.target || customOpts)) {
16
18
  const former = srv[OUTBOXED];
@@ -102,18 +104,27 @@ const _mapToEventAndPublish = async (req, namespace, subType, eventHeaders, cont
102
104
  }
103
105
  }
104
106
  }
105
-
106
107
  const event = {
107
108
  contextUser: context.user.id,
108
109
  ...(req._fromSend || (req.reply && { _fromSend: true })), // send or emit
109
- ...(req.inbound && { inbound: req.inbound }),
110
- ...(req.event && { event: req.event }),
111
- ...(req.data && { data: req.data }),
112
110
  ...(eventHeaders && { headers: eventHeaders }),
113
- ...(req.query && { query: req.query }),
114
111
  ...(Object.keys(contextProperties).length && { ...contextProperties }),
115
112
  };
116
113
 
114
+ for (const prop of TO_COPY) {
115
+ if (req[prop]) {
116
+ event[prop] = req[prop];
117
+ }
118
+ }
119
+
120
+ if (req.query) {
121
+ event.query = typeof req.query.flat === "function" ? req.query.flat() : req.query;
122
+ delete event.query._target;
123
+ delete event.query.__target;
124
+ delete event.query.target;
125
+ delete event.data; // `req.data` should be a getter to whatever is in `req.query`
126
+ }
127
+
117
128
  await publishEvent(
118
129
  cds.tx(context),
119
130
  {
@@ -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
  }
@@ -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 };