@cap-js-community/event-queue 1.10.0-beta.4 → 1.10.0-beta.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "1.10.0-beta.4",
3
+ "version": "1.10.0-beta.6",
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": ">=18"
45
45
  },
46
46
  "dependencies": {
47
- "@sap/xssec": "^4.2.4",
48
- "cron-parser": "^5.0.0",
47
+ "@sap/xssec": "^4.5.0",
48
+ "cron-parser": "^5.1.0",
49
49
  "redis": "^4.7.0",
50
50
  "verror": "^1.10.1",
51
- "yaml": "^2.6.1"
51
+ "yaml": "^2.7.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@cap-js/cds-test": "^0.2.0",
@@ -806,10 +806,10 @@ class EventQueueProcessorBase {
806
806
  currentAttempt: exceededEvent.attempts,
807
807
  }
808
808
  );
809
+ await tx.rollback();
809
810
  await executeInNewTransaction(this.__baseContext, "error-hookForExceededEvents", async (tx) =>
810
811
  this.#persistEventQueueStatusForExceeded(tx, [exceededEvent], EventProcessingStatus.Error)
811
812
  );
812
- await tx.rollback();
813
813
  }
814
814
  }
815
815
  );
package/src/config.js CHANGED
@@ -24,6 +24,7 @@ const DEFAULT_INCREASE_PRIORITY = true;
24
24
  const DEFAULT_KEEP_ALIVE_INTERVAL = 60;
25
25
  const DEFAULT_MAX_FACTOR_STUCK_2_KEEP_ALIVE_INTERVAL = 3.5;
26
26
  const DEFAULT_INHERIT_TRACE_CONTEXT = true;
27
+ const DEFAULT_CHECK_FOR_NEXT_CHUNK = true;
27
28
  const SUFFIX_PERIODIC = "_PERIODIC";
28
29
  const CAP_EVENT_TYPE = "CAP_OUTBOX";
29
30
  const CAP_PARALLEL_DEFAULT = 5;
@@ -152,18 +153,50 @@ class Config {
152
153
  return !!this.#env.redisRequires?.credentials;
153
154
  }
154
155
 
156
+ #parseRegexOrString(str) {
157
+ const regexLiteralPattern = /^\/((?:\\.|[^\\/])*)\/([gimsuy]*)$/;
158
+ const match = str.match(regexLiteralPattern);
159
+
160
+ if (match) {
161
+ try {
162
+ return { type: "regex", value: new RegExp(match[1], match[2]) };
163
+ } catch {
164
+ return { type: "string", value: str };
165
+ }
166
+ }
167
+ return { type: "string", value: str };
168
+ }
169
+
155
170
  shouldBeProcessedInThisApplication(type, subType) {
156
171
  const config = this.#eventMap[this.generateKey(type, subType)];
157
172
  const appNameConfig = config._appNameMap;
158
173
  const appInstanceConfig = config._appInstancesMap;
174
+ let result = true;
159
175
  if (!appNameConfig && !appInstanceConfig) {
160
- return true;
176
+ return result;
161
177
  }
162
178
 
163
179
  if (appNameConfig) {
164
- const shouldBeProcessedBasedOnAppName = appNameConfig[this.#env.applicationName];
165
- if (!shouldBeProcessedBasedOnAppName) {
166
- return false;
180
+ if (config._appNameContainsRegex) {
181
+ for (const configKey in appNameConfig) {
182
+ const config = appNameConfig[configKey];
183
+ if (config.type === "regex") {
184
+ result = config.value.test(this.#env.applicationName);
185
+ } else {
186
+ const shouldBeProcessedBasedOnAppName = appNameConfig[this.#env.applicationName];
187
+ if (!shouldBeProcessedBasedOnAppName) {
188
+ result = config.value === this.#env.applicationName;
189
+ }
190
+ }
191
+ if (result) {
192
+ break;
193
+ }
194
+ }
195
+ } else {
196
+ const shouldBeProcessedBasedOnAppName = appNameConfig[this.#env.applicationName];
197
+ if (!shouldBeProcessedBasedOnAppName) {
198
+ return false;
199
+ }
167
200
  }
168
201
  }
169
202
 
@@ -174,7 +207,7 @@ class Config {
174
207
  }
175
208
  }
176
209
 
177
- return true;
210
+ return result;
178
211
  }
179
212
 
180
213
  checkRedisEnabled() {
@@ -335,6 +368,7 @@ class Config {
335
368
  this.#config.events.splice(index, 1);
336
369
  }
337
370
 
371
+ // NOTE: CAP outbox defaults are injected by cds.requires.outbox
338
372
  const eventConfig = this.#sanitizeParamsAdHocEvent({
339
373
  type: CAP_EVENT_TYPE,
340
374
  subType: serviceName,
@@ -507,6 +541,7 @@ class Config {
507
541
  event.increasePriorityOverTime = event.increasePriorityOverTime ?? DEFAULT_INCREASE_PRIORITY;
508
542
  event.keepAliveInterval = (event.keepAliveInterval ?? DEFAULT_KEEP_ALIVE_INTERVAL) * 1000;
509
543
  event.keepAliveMaxInProgressTime = event.keepAliveInterval * DEFAULT_MAX_FACTOR_STUCK_2_KEEP_ALIVE_INTERVAL;
544
+ event.checkForNextChunk = event.checkForNextChunk ?? DEFAULT_CHECK_FOR_NEXT_CHUNK;
510
545
  }
511
546
 
512
547
  #sanitizeParamsBase(config, allowList) {
@@ -527,7 +562,12 @@ class Config {
527
562
  }
528
563
 
529
564
  #basicEventTransformationAfterValidate(event) {
530
- event._appNameMap = event.appNames ? Object.fromEntries(new Map(event.appNames.map((a) => [a, true]))) : null;
565
+ event._appNameMap = event.appNames
566
+ ? Object.fromEntries(new Map(event.appNames.map((a) => [a, this.#parseRegexOrString(a)])))
567
+ : null;
568
+ event._appNameContainsRegex = event.appNames
569
+ ? event.appNames.some((appName) => this.#parseRegexOrString(appName).type === "regex")
570
+ : null;
531
571
  event._appInstancesMap = event.appInstances
532
572
  ? Object.fromEntries(new Map(event.appInstances.map((a) => [a, true])))
533
573
  : null;
@@ -9,6 +9,12 @@ const config = require("../config");
9
9
 
10
10
  const COMPONENT_NAME = "/eventQueue/outbox/generic";
11
11
 
12
+ const EVENT_QUEUE_ACTIONS = {
13
+ EXCEEDED: "eventQueueRetriesExceeded",
14
+ CLUSTER: "eventQueueCluster",
15
+ CHECK_AND_ADJUST: "eventQueueCheckAndAdjustPayload",
16
+ };
17
+
12
18
  class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
13
19
  constructor(context, eventType, eventSubType, config) {
14
20
  super(context, eventType, eventSubType, config);
@@ -21,7 +27,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
21
27
  this.__srvUnboxed = cds.unboxed(this.__srv);
22
28
  const { handlers, clusterRelevant, specificClusterRelevant } = this.__srvUnboxed.handlers.on.reduce(
23
29
  (result, handler) => {
24
- if (handler.on.startsWith("clusterQueueEntries")) {
30
+ if (handler.on.startsWith(EVENT_QUEUE_ACTIONS.CLUSTER)) {
25
31
  if (handler.on.split(".").length === 2) {
26
32
  result.specificClusterRelevant = true;
27
33
  } else {
@@ -38,26 +44,16 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
38
44
  this.__genericClusterHandler = clusterRelevant;
39
45
  this.__specificClusterHandler = specificClusterRelevant;
40
46
  await this.#setContextUser(this.context, config.userId);
41
- return super.getQueueEntriesAndSetToInProgress();
47
+ return await super.getQueueEntriesAndSetToInProgress();
42
48
  }
43
49
 
44
- // NOTE: issue here: if events are not sorted before it might not be unique here:
45
- // - we have service events
46
- // - we have action specific events
47
- // 1. I need to collect all action names
48
- // 2. I need to check for which actions a handler exits
49
- // 3. For actions with specific existing handler --> call specific action
50
- // 4. For all others call generic handler
51
- // NOTE: OVERALL idea is that the handler returns the cluster map and MUST not call any baseImpl functions!
52
- // --> structure is a map of { key: { queueEntries: [], payload: {} }
53
- // TODO: document that clusterQueueEntries is now async!!!
54
- // TODO: validate that return structure is as expected
55
50
  async clusterQueueEntries(queueEntriesWithPayloadMap) {
56
51
  if (!this.__genericClusterRelevantAndAvailable && !this.__specificClusterRelevantAndAvailable) {
57
52
  return super.clusterQueueEntries(queueEntriesWithPayloadMap);
58
53
  }
59
54
  const { genericClusterEvents, specificClusterEvents } = this.#clusterByAction(queueEntriesWithPayloadMap);
60
55
 
56
+ const clusterMap = {};
61
57
  if (Object.keys(genericClusterEvents).length) {
62
58
  if (!this.__genericClusterRelevantAndAvailable) {
63
59
  for (const actionName in genericClusterEvents) {
@@ -66,39 +62,91 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
66
62
  } else {
67
63
  for (const actionName in genericClusterEvents) {
68
64
  const msg = new cds.Request({
69
- event: `clusterQueueEntries`,
65
+ event: EVENT_QUEUE_ACTIONS.CLUSTER,
66
+ user: this.context.user,
70
67
  eventQueue: {
71
68
  processor: this,
72
69
  clusterByPayloadProperty: (propertyName, cb) =>
73
- this.clusterByPayloadProperty(actionName, genericClusterEvents[actionName], propertyName, cb),
70
+ this.#clusterByPayloadProperty(actionName, genericClusterEvents[actionName], propertyName, cb),
74
71
  clusterByEventProperty: (propertyName, cb) =>
75
- this.clusterByEventProperty(actionName, genericClusterEvents[actionName], propertyName, cb),
72
+ this.#clusterByEventProperty(actionName, genericClusterEvents[actionName], propertyName, cb),
76
73
  clusterByDataProperty: (propertyName, cb) =>
77
- this.clusterByDataProperty(actionName, specificClusterEvents[actionName], propertyName, cb),
74
+ this.#clusterByDataProperty(actionName, genericClusterEvents[actionName], propertyName, cb),
78
75
  },
79
76
  });
80
- const handlerCluster = await this.__srvUnboxed.tx(this.context).send(msg);
81
- this.#addToProcessingMap(handlerCluster);
77
+ const clusterResult = await this.__srvUnboxed.tx(this.context).send(msg);
78
+ if (this.#validateCluster(clusterResult)) {
79
+ Object.assign(clusterMap, clusterResult);
80
+ } else {
81
+ this.logger.error(
82
+ "cluster result of handler is not valid. Check the documentation for the expected structure. Continuing without clustering!",
83
+ {
84
+ handler: msg.event,
85
+ clusterResult: JSON.stringify(clusterResult),
86
+ }
87
+ );
88
+ return super.clusterQueueEntries(queueEntriesWithPayloadMap);
89
+ }
82
90
  }
83
91
  }
84
92
  }
85
93
 
86
94
  for (const actionName in specificClusterEvents) {
87
95
  const msg = new cds.Request({
88
- event: `clusterQueueEntries.${actionName}`,
96
+ event: `${EVENT_QUEUE_ACTIONS.CLUSTER}.${actionName}`,
97
+ user: this.context.user,
89
98
  eventQueue: {
90
99
  processor: this,
91
100
  clusterByPayloadProperty: (propertyName, cb) =>
92
- this.clusterByPayloadProperty(actionName, specificClusterEvents[actionName], propertyName, cb),
101
+ this.#clusterByPayloadProperty(actionName, specificClusterEvents[actionName], propertyName, cb),
93
102
  clusterByEventProperty: (propertyName, cb) =>
94
- this.clusterByEventProperty(actionName, specificClusterEvents[actionName], propertyName, cb),
103
+ this.#clusterByEventProperty(actionName, specificClusterEvents[actionName], propertyName, cb),
95
104
  clusterByDataProperty: (propertyName, cb) =>
96
- this.clusterByDataProperty(actionName, specificClusterEvents[actionName], propertyName, cb),
105
+ this.#clusterByDataProperty(actionName, specificClusterEvents[actionName], propertyName, cb),
97
106
  },
98
107
  });
99
- const handlerCluster = await this.__srvUnboxed.tx(this.context).send(msg);
100
- this.#addToProcessingMap(handlerCluster);
108
+ const clusterResult = await this.__srvUnboxed.tx(this.context).send(msg);
109
+ if (this.#validateCluster(clusterResult)) {
110
+ Object.assign(clusterMap, clusterResult);
111
+ } else {
112
+ this.logger.error(
113
+ "cluster result of handler is not valid. Check the documentation for the expected structure. Continuing without clustering!",
114
+ {
115
+ handler: msg.event,
116
+ clusterResult: JSON.stringify(clusterResult),
117
+ }
118
+ );
119
+ return super.clusterQueueEntries(queueEntriesWithPayloadMap);
120
+ }
101
121
  }
122
+ this.#addToProcessingMap(clusterMap);
123
+ }
124
+
125
+ #validateCluster(obj) {
126
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
127
+ return false;
128
+ }
129
+
130
+ for (const key of Object.keys(obj)) {
131
+ const clusterEntry = obj[key];
132
+ if (typeof clusterEntry !== "object" || clusterEntry === null || Array.isArray(obj)) {
133
+ return false;
134
+ }
135
+
136
+ if (!Array.isArray(clusterEntry.queueEntries)) {
137
+ return false;
138
+ }
139
+
140
+ if (
141
+ typeof clusterEntry.payload !== "object" ||
142
+ clusterEntry.payload === null ||
143
+ Array.isArray(clusterEntry.payload)
144
+ ) {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ return true;
102
150
  }
103
151
 
104
152
  clusterBase(queueEntriesWithPayloadMap, propertyName, refCb, cb) {
@@ -139,7 +187,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
139
187
  return result[key];
140
188
  }
141
189
 
142
- clusterByPayloadProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) {
190
+ #clusterByPayloadProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) {
143
191
  return this.clusterBase(
144
192
  queueEntriesWithPayloadMap,
145
193
  propertyName,
@@ -148,7 +196,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
148
196
  );
149
197
  }
150
198
 
151
- clusterByEventProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) {
199
+ #clusterByEventProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) {
152
200
  return this.clusterBase(
153
201
  queueEntriesWithPayloadMap,
154
202
  propertyName,
@@ -157,7 +205,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
157
205
  );
158
206
  }
159
207
 
160
- clusterByDataProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) {
208
+ #clusterByDataProperty(actionName, queueEntriesWithPayloadMap, propertyName, cb) {
161
209
  return this.clusterBase(
162
210
  queueEntriesWithPayloadMap,
163
211
  propertyName,
@@ -205,13 +253,13 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
205
253
  }
206
254
 
207
255
  #hasEventSpecificClusterHandler(queueEntry) {
208
- return !!this.__onHandlers[["clusterQueueEntries", queueEntry.payload.event].join(".")];
256
+ return !!this.__onHandlers[[EVENT_QUEUE_ACTIONS.CLUSTER, queueEntry.payload.event].join(".")];
209
257
  }
210
258
 
211
259
  async checkEventAndGeneratePayload(queueEntry) {
212
260
  const payload = await super.checkEventAndGeneratePayload(queueEntry);
213
261
  const { event } = payload;
214
- const handlerName = this.#checkHandlerExists("checkEventAndGeneratePayload", event);
262
+ const handlerName = this.#checkHandlerExists(EVENT_QUEUE_ACTIONS.CHECK_AND_ADJUST, event);
215
263
  if (!handlerName) {
216
264
  return payload;
217
265
  }
@@ -230,18 +278,26 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
230
278
  }
231
279
  }
232
280
 
233
- // simple here as per entry
234
281
  async hookForExceededEvents(exceededEvent) {
235
- return await super.hookForExceededEvents(exceededEvent);
282
+ const { event } = exceededEvent.payload;
283
+ const handlerName = this.#checkHandlerExists(EVENT_QUEUE_ACTIONS.EXCEEDED, event);
284
+ if (!handlerName) {
285
+ return await super.hookForExceededEvents(exceededEvent);
286
+ }
287
+
288
+ const { msg, userId } = this.#buildDispatchData(this.context, exceededEvent.payload, {
289
+ queueEntries: [exceededEvent],
290
+ });
291
+ await this.#setContextUser(this.context, userId, msg);
292
+ msg.event = handlerName;
293
+ await this.__srvUnboxed.tx(this.context).send(msg);
236
294
  }
237
295
 
296
+ // NOTE: Currently not exposed to CAP service; we wait for a valid use case
238
297
  async beforeProcessingEvents() {
239
298
  return await super.beforeProcessingEvents();
240
299
  }
241
300
 
242
- // maybe async getter on req.data // only for periodic events
243
- // getLastSuccessfulRunTimestamp
244
-
245
301
  #checkHandlerExists(eventQueueFn, event) {
246
302
  const specificHandler = this.__onHandlers[[eventQueueFn, event].join(".")];
247
303
  if (specificHandler) {
@@ -63,8 +63,8 @@ const publishEvent = async (
63
63
  event.context = JSON.stringify({ traceContext: openTelemetry.getCurrentTraceContext() });
64
64
  }
65
65
 
66
- if (eventConfig.timeBucket) {
67
- event.startAfter ??= CronExpressionParser.parse(eventConfig.timeBucket).next().toISOString();
66
+ if (eventConfig.timeBucket && event.startAfter !== undefined) {
67
+ event.startAfter = CronExpressionParser.parse(eventConfig.timeBucket).next().toISOString();
68
68
  }
69
69
  }
70
70
  if (config.insertEventsBeforeCommit && !skipInsertEventsBeforeCommit) {