@cap-js-community/event-queue 2.0.0 → 2.0.2

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": "2.0.0",
3
+ "version": "2.0.2",
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",
@@ -56,10 +56,11 @@
56
56
  "devDependencies": {
57
57
  "@actions/core": "^1.11.1",
58
58
  "@cap-js/cds-test": "^0.4.0",
59
+ "@cap-js/db-service": "^2.6.0",
59
60
  "@cap-js/hana": "^2.3.4",
60
- "@cap-js/sqlite": "^2.0.4",
61
+ "@cap-js/sqlite": "^2.1.0",
61
62
  "@opentelemetry/api": "^1.9.0",
62
- "@sap/cds": "^9.4.4",
63
+ "@sap/cds": "^9.4.5",
63
64
  "@sap/cds-dk": "^9.4.2",
64
65
  "eslint": "^8.57.0",
65
66
  "eslint-config-prettier": "^9.1.0",
@@ -27,12 +27,16 @@ const ERROR_CODES = {
27
27
  APP_NAMES_FORMAT: "APP_NAMES_FORMAT",
28
28
  APP_INSTANCES_FORMAT: "APP_INSTANCES_FORMAT",
29
29
  MULTI_INSTANCE_PROCESSING_NOT_ALLOWED: "MULTI_INSTANCE_PROCESSING_NOT_ALLOWED",
30
+ INVALID_CLUSTER_HANDLER_RESULT: "INVALID_CLUSTER_HANDLER_RESULT",
30
31
  };
31
32
 
32
33
  const ERROR_CODES_META = {
33
34
  [ERROR_CODES.WRONG_TX_USAGE]: {
34
35
  message: "Usage of this.tx|this.context is not allowed if parallel event processing is enabled",
35
36
  },
37
+ [ERROR_CODES.INVALID_CLUSTER_HANDLER_RESULT]: {
38
+ message: "A cluster handler returned an invalid result. Cluster handlers must return the clustered payload data.",
39
+ },
36
40
  [ERROR_CODES.UNKNOWN_EVENT_TYPE]: {
37
41
  message: "The event type and subType configuration is not configured! Maintain the combination in the config file.",
38
42
  },
@@ -371,6 +375,17 @@ class EventQueueError extends VError {
371
375
  );
372
376
  }
373
377
 
378
+ static invalidClusterHandlerResult(clusterKey, propertyName) {
379
+ const { message } = ERROR_CODES_META[ERROR_CODES.INVALID_CLUSTER_HANDLER_RESULT];
380
+ return new EventQueueError(
381
+ {
382
+ name: ERROR_CODES.INVALID_CLUSTER_HANDLER_RESULT,
383
+ info: { clusterKey, propertyName },
384
+ },
385
+ message
386
+ );
387
+ }
388
+
374
389
  static isRedisConnectionFailure(err) {
375
390
  return err instanceof VError && err.name === ERROR_CODES.REDIS_CREATE_CLIENT;
376
391
  }
@@ -62,6 +62,7 @@ class EventQueueProcessorBase {
62
62
  this.#eventType = eventType;
63
63
  this.#eventSubType = eventSubType;
64
64
  this.#eventConfig = config ?? {};
65
+ this.#eventConfig.selectedDelayedEventIds ??= [];
65
66
  this.__parallelEventProcessing = this.#eventConfig.parallelEventProcessing ?? DEFAULT_PARALLEL_EVENT_PROCESSING;
66
67
  if (this.__parallelEventProcessing > LIMIT_PARALLEL_EVENT_PROCESSING) {
67
68
  this.__parallelEventProcessing = LIMIT_PARALLEL_EVENT_PROCESSING;
@@ -664,45 +665,49 @@ class EventQueueProcessorBase {
664
665
  const baseDate = Date.now();
665
666
  const refDateStartAfter = new Date(baseDate + this.#config.runInterval * 1.2);
666
667
  await executeInNewTransaction(this.__baseContext, "eventQueue-getQueueEntriesAndSetToInProgress", async (tx) => {
667
- const entries = await tx.run(
668
- SELECT.from(this.#config.tableNameEventQueue)
669
- .forUpdate({ wait: this.#config.forUpdateTimeout })
670
- .limit(this.selectMaxChunkSize)
671
- .where(
672
- "type =",
673
- this.#eventType,
674
- "AND subType=",
675
- this.#eventSubType,
676
- "AND namespace =",
677
- this.#namespace,
678
- "AND ( startAfter IS NULL OR startAfter <=",
679
- refDateStartAfter.toISOString(),
680
- " ) AND ( status =",
681
- EventProcessingStatus.Open,
682
- "AND ( lastAttemptTimestamp <=",
683
- this.startTime.toISOString(),
684
- ...(this.isPeriodicEvent
685
- ? [
686
- "OR lastAttemptTimestamp IS NULL ) OR ( status =",
687
- EventProcessingStatus.InProgress,
688
- "AND lastAttemptTimestamp <=",
689
- new Date(baseDate - this.#eventConfig.keepAliveMaxInProgressTime * 1000).toISOString(),
690
- ") )",
691
- ]
692
- : [
693
- "OR lastAttemptTimestamp IS NULL ) OR ( status =",
694
- EventProcessingStatus.Error,
695
- "AND lastAttemptTimestamp <=",
696
- this.startTime.toISOString(),
697
- ") OR ( status =",
698
- EventProcessingStatus.InProgress,
699
- "AND lastAttemptTimestamp <=",
700
- new Date(baseDate - this.#eventConfig.keepAliveMaxInProgressTime * 1000).toISOString(),
701
- ") )",
702
- ])
703
- )
704
- .orderBy("createdAt", "ID")
705
- );
668
+ const cqn = SELECT.from(this.#config.tableNameEventQueue)
669
+ .forUpdate({ wait: this.#config.forUpdateTimeout })
670
+ .limit(this.selectMaxChunkSize)
671
+ .where(
672
+ "type =",
673
+ this.#eventType,
674
+ "AND subType=",
675
+ this.#eventSubType,
676
+ "AND namespace =",
677
+ this.#namespace,
678
+ "AND ( startAfter IS NULL OR startAfter <=",
679
+ refDateStartAfter.toISOString(),
680
+ " ) AND ( status =",
681
+ EventProcessingStatus.Open,
682
+ "AND ( lastAttemptTimestamp <=",
683
+ this.startTime.toISOString(),
684
+ ...(this.isPeriodicEvent
685
+ ? [
686
+ "OR lastAttemptTimestamp IS NULL ) OR ( status =",
687
+ EventProcessingStatus.InProgress,
688
+ "AND lastAttemptTimestamp <=",
689
+ new Date(baseDate - this.#eventConfig.keepAliveMaxInProgressTime * 1000).toISOString(),
690
+ ") ) ",
691
+ ]
692
+ : [
693
+ "OR lastAttemptTimestamp IS NULL ) OR ( status =",
694
+ EventProcessingStatus.Error,
695
+ "AND lastAttemptTimestamp <=",
696
+ this.startTime.toISOString(),
697
+ ") OR ( status =",
698
+ EventProcessingStatus.InProgress,
699
+ "AND lastAttemptTimestamp <=",
700
+ new Date(baseDate - this.#eventConfig.keepAliveMaxInProgressTime * 1000).toISOString(),
701
+ ") )",
702
+ ])
703
+ )
704
+ .orderBy("createdAt", "ID");
705
+
706
+ if (this.#eventConfig.selectedDelayedEventIds) {
707
+ cqn.where("ID NOT IN", this.#eventConfig.selectedDelayedEventIds);
708
+ }
709
+
710
+ const entries = await tx.run(cqn);
706
711
 
707
712
  if (!entries.length) {
708
713
  this.logger.debug("no entries available for processing", {
@@ -756,7 +761,9 @@ class EventQueueProcessorBase {
756
761
  }
757
762
 
758
763
  if (!eventsForProcessing.length) {
759
- this.__emptyChunkSelected = true;
764
+ if (!entries.length) {
765
+ this.__emptyChunkSelected = true;
766
+ }
760
767
  return;
761
768
  }
762
769
 
@@ -806,8 +813,11 @@ class EventQueueProcessorBase {
806
813
  return entry.lastAttemptsTs;
807
814
  }
808
815
 
809
- #handleDelayedEvents(delayedEvents) {
816
+ #handleDelayedEvents(delayedEvents, { skipExcludeDelayedEventIds = false } = {}) {
810
817
  for (const delayedEvent of delayedEvents) {
818
+ if (!skipExcludeDelayedEventIds) {
819
+ this.#eventConfig.selectedDelayedEventIds.push(delayedEvent.ID);
820
+ }
811
821
  this.#eventSchedulerInstance.scheduleEvent(
812
822
  this.__context.tenant,
813
823
  this.#eventType,
@@ -1031,7 +1041,7 @@ class EventQueueProcessorBase {
1031
1041
  const lockAcquired = await distributedLock.acquireLock(
1032
1042
  this.__context,
1033
1043
  [this.#namespace, this.#eventType, this.#eventSubType].join("##"),
1034
- { keepTrackOfLock: true, expiryTime: this.#eventConfig.keepAliveMaxInProgressTime * 1000 }
1044
+ { keepTrackOfLock: true, expiryTime: this.#eventConfig.keepAliveMaxInProgressTime * 1000, skipNamespace: true }
1035
1045
  );
1036
1046
  if (!lockAcquired) {
1037
1047
  this.logger.debug("no lock available, exit processing", {
@@ -1074,7 +1084,8 @@ class EventQueueProcessorBase {
1074
1084
  await trace(this.baseContext, "release-lock", async () => {
1075
1085
  await distributedLock.releaseLock(
1076
1086
  this.context,
1077
- [this.#namespace, this.#eventType, this.#eventSubType].join("##")
1087
+ [this.#namespace, this.#eventType, this.#eventSubType].join("##"),
1088
+ { skipNamespace: true }
1078
1089
  );
1079
1090
  });
1080
1091
  } catch (err) {
@@ -1106,6 +1117,7 @@ class EventQueueProcessorBase {
1106
1117
  }
1107
1118
 
1108
1119
  const newEvent = {
1120
+ ID: cds.utils.uuid(),
1109
1121
  type: this.#eventType,
1110
1122
  subType: this.#eventSubType,
1111
1123
  namespace: this.#eventConfig.namespace,
@@ -1130,16 +1142,16 @@ class EventQueueProcessorBase {
1130
1142
  });
1131
1143
  }
1132
1144
 
1133
- this.tx._skipEventQueueBroadcase = true;
1145
+ this.tx._skipEventQueueBroadcast = true;
1134
1146
  await this.tx.run(
1135
1147
  INSERT.into(this.#config.tableNameEventQueue).entries({
1136
1148
  ...newEvent,
1137
1149
  startAfter: newEvent.startAfter.toISOString(),
1138
1150
  })
1139
1151
  );
1140
- this.tx._skipEventQueueBroadcase = false;
1152
+ this.tx._skipEventQueueBroadcast = false;
1141
1153
  if (intervalInMs < this.#config.runInterval * 1.5) {
1142
- this.#handleDelayedEvents([newEvent]);
1154
+ this.#handleDelayedEvents([newEvent], { skipExcludeDelayedEventIds: true });
1143
1155
  const { relative: relativeAfterSchedule } = this.#eventSchedulerInstance.calculateOffset(
1144
1156
  this.#eventType,
1145
1157
  this.#eventSubType,
package/src/config.js CHANGED
@@ -252,9 +252,8 @@ class Config {
252
252
  this.#unsubscribeHandlers.push(cb);
253
253
  }
254
254
 
255
- addCAPOutboxEventBase(serviceName, config) {
255
+ addCAPOutboxEventBase(serviceName, config, currentEvents) {
256
256
  const namespace = config.namespace ?? this.#namespace;
257
- delete this.#eventMap[this.generateKey(namespace, CAP_EVENT_TYPE, serviceName)];
258
257
 
259
258
  // NOTE: CAP outbox defaults are injected by cds.requires.outbox // cds.requires.queue
260
259
  const eventConfig = this.#sanitizeParamsAdHocEvent({
@@ -262,44 +261,45 @@ class Config {
262
261
  subType: serviceName,
263
262
  impl: "./outbox/EventQueueGenericOutboxHandler",
264
263
  kind: config.kind ?? "persistent-queue",
265
- selectMaxChunkSize: config.selectMaxChunkSize ?? config.chunkSize,
266
- parallelEventProcessing: config.parallelEventProcessing ?? (config.parallel && CAP_PARALLEL_DEFAULT),
267
- retryAttempts: config.retryAttempts ?? config.maxAttempts ?? CAP_MAX_ATTEMPTS_DEFAULT,
264
+ ...this.#mixCAPPropertyNamesWithEventQueueNames(config),
268
265
  namespace,
269
266
  ...config,
270
267
  });
268
+ eventConfig.retryAttempts ??= CAP_MAX_ATTEMPTS_DEFAULT;
271
269
  eventConfig.internalEvent = true;
272
270
 
273
271
  this.#basicEventTransformation(eventConfig);
274
- this.#validateAdHocEvents(this.#eventMap, eventConfig, false);
272
+ this.#validateAdHocEvents(currentEvents, eventConfig, false);
275
273
  this.#basicEventTransformationAfterValidate(eventConfig);
276
- this.#eventMap[this.generateKey(namespace, CAP_EVENT_TYPE, serviceName)] = eventConfig;
274
+ return [this.generateKey(namespace, CAP_EVENT_TYPE, serviceName), eventConfig];
277
275
  }
278
276
 
279
- addCAPOutboxEventSpecificAction(serviceName, actionName) {
280
- const subType = [serviceName, actionName].join(".");
277
+ #mixCAPPropertyNamesWithEventQueueNames(config) {
278
+ return this.#cleanUndefined({
279
+ selectMaxChunkSize: config.selectMaxChunkSize ?? config.chunkSize,
280
+ parallelEventProcessing: config.parallelEventProcessing ?? (config.parallel && CAP_PARALLEL_DEFAULT),
281
+ retryAttempts: config.retryAttempts ?? config.maxAttempts,
282
+ ...config,
283
+ });
284
+ }
281
285
 
282
- const specific = this.#findBaseCAPServiceWithoutNamespace(subType);
283
- if (specific) {
284
- delete this.#eventMap[this.generateKey(specific.namespace, CAP_EVENT_TYPE, subType)];
285
- }
286
+ addCAPOutboxEventSpecificAction(baseServiceConfig, serviceName, actionName, currentEvents) {
287
+ const subType = [serviceName, actionName].join(".");
286
288
 
287
- const base = this.#findBaseCAPServiceWithoutNamespace(serviceName);
288
289
  const eventConfig = this.#sanitizeParamsAdHocEvent({
289
- ...this.getEventConfig(CAP_EVENT_TYPE, serviceName, base.namespace),
290
+ ...baseServiceConfig,
290
291
  ...this.getCdsOutboxEventSpecificConfig(serviceName, actionName),
291
292
  subType,
292
293
  });
293
294
  eventConfig.internalEvent = true;
294
295
 
295
296
  this.#basicEventTransformation(eventConfig);
296
- this.#validateAdHocEvents(this.#eventMap, eventConfig, false);
297
+ this.#validateAdHocEvents(currentEvents, eventConfig, false);
297
298
  this.#basicEventTransformationAfterValidate(eventConfig);
298
- this.#eventMap[this.generateKey(eventConfig.namespace, CAP_EVENT_TYPE, subType)] = eventConfig;
299
- return eventConfig;
299
+ return [this.generateKey(eventConfig.namespace, CAP_EVENT_TYPE, subType), eventConfig];
300
300
  }
301
301
 
302
- #findBaseCAPServiceWithoutNamespace(serviceName) {
302
+ findBaseCAPServiceWithoutNamespace(serviceName) {
303
303
  return Object.values(this.#eventMap).find(
304
304
  (event) => event.type === CAP_EVENT_TYPE && event.subType === serviceName
305
305
  );
@@ -318,22 +318,39 @@ class Config {
318
318
  fileContent.periodicEvents ??= [];
319
319
  const events = this.#configEvents ?? {};
320
320
  const periodicEvents = this.#configPeriodicEvents ?? {};
321
- const periodicCapServiceEvents = this.#cdsPeriodicOutboxServicesFromEnv();
322
- fileContent.events = fileContent.events.concat(this.#mapEnvEvents(events));
321
+ const { adHoc: adHocCAP, periodicEvents: periodicEventsCAP } = this.#cdsQueueEventsFromEnv();
322
+ fileContent.events = fileContent.events.concat(this.#mapEnvEvents(events)).concat(Object.values(adHocCAP));
323
323
  fileContent.periodicEvents = fileContent.periodicEvents
324
324
  .concat(this.#mapEnvEvents(periodicEvents))
325
- .concat(this.#mapCapOutboxPeriodicEvent(periodicCapServiceEvents));
325
+ .concat(Object.values(periodicEventsCAP));
326
326
  this.fileContent = fileContent;
327
327
  }
328
328
 
329
- #mapCapOutboxPeriodicEvent(periodicEventMap) {
330
- return Object.values(periodicEventMap);
329
+ #getBasicCapOutboxEventConfig(serviceName) {
330
+ return Object.assign(
331
+ {},
332
+ (typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
333
+ (typeof cds.requires.queue === "object" && cds.requires.queue) || {},
334
+ (typeof cds.env.requires[serviceName]?.outbox === "object" && cds.env.requires[serviceName].outbox) || {},
335
+ (typeof cds.env.requires[serviceName]?.queued === "object" && cds.env.requires[serviceName].queued) || {}
336
+ );
331
337
  }
332
338
 
333
- #cdsPeriodicOutboxServicesFromEnv() {
334
- return Object.entries(cds.env.requires).reduce((result, [name, value]) => {
335
- const config = value.outbox ?? value.queued;
336
- if (config?.events) {
339
+ #cdsQueueEventsFromEnv() {
340
+ return Object.entries(cds.env.requires).reduce(
341
+ (result, [name, value]) => {
342
+ const config = value.outbox ?? value.queued;
343
+
344
+ if (!config) {
345
+ return result;
346
+ }
347
+
348
+ // make sure service is known in general
349
+ // only if not known
350
+ const basicServiceConfig = this.#getBasicCapOutboxEventConfig(name);
351
+ const [key, srvConfig] = this.addCAPOutboxEventBase(name, basicServiceConfig, result.adHoc);
352
+ result.adHoc[key] = srvConfig;
353
+
337
354
  for (const fnName in config.events) {
338
355
  const base = { ...config };
339
356
  const fnConfig = config.events[fnName];
@@ -348,7 +365,7 @@ class Config {
348
365
  }
349
366
 
350
367
  const subType = `${name}.${fnName}`;
351
- result[subType] = Object.assign(
368
+ result.periodicEvents[subType] = Object.assign(
352
369
  {
353
370
  type: CAP_EVENT_TYPE,
354
371
  subType,
@@ -358,18 +375,38 @@ class Config {
358
375
  base,
359
376
  fnConfig
360
377
  );
378
+ } else {
379
+ const [key, specificEventConfig] = this.addCAPOutboxEventSpecificAction(
380
+ srvConfig,
381
+ name,
382
+ fnName,
383
+ result.adHoc
384
+ );
385
+ result.adHoc[key] = specificEventConfig;
361
386
  }
362
387
  }
363
- }
364
- return result;
365
- }, {});
388
+ return result;
389
+ },
390
+ { adHoc: {}, periodicEvents: {} }
391
+ );
392
+ }
393
+
394
+ addCAPServiceWithoutEnvConfig(serviceName, srv, customOpts = {}) {
395
+ const queueOptions = srv.options.queued ?? srv.options.outbox ?? {};
396
+ const basicServiceConfig = this.#getBasicCapOutboxEventConfig(serviceName);
397
+ const [key, srvConfig] = this.addCAPOutboxEventBase(
398
+ serviceName,
399
+ { ...basicServiceConfig, ...queueOptions, ...customOpts },
400
+ this.#eventMap
401
+ );
402
+ this.#eventMap[key] = srvConfig;
366
403
  }
367
404
 
368
405
  getCdsOutboxEventSpecificConfig(serviceName, action) {
369
406
  const srv = cds.env.requires[serviceName];
370
407
  const config = srv?.outbox ?? srv?.queued;
371
408
  if (config?.events?.[action]) {
372
- return config.events[action];
409
+ return this.#mixCAPPropertyNamesWithEventQueueNames(config.events[action]);
373
410
  } else {
374
411
  return null;
375
412
  }
@@ -877,6 +914,15 @@ class Config {
877
914
  this.#processingNamespaces = value;
878
915
  }
879
916
 
917
+ #cleanUndefined(input) {
918
+ return Object.entries(input).reduce((acc, [key, value]) => {
919
+ if (value !== undefined) {
920
+ acc[key] = value;
921
+ }
922
+ return acc;
923
+ }, {});
924
+ }
925
+
880
926
  /**
881
927
  @return { Config }
882
928
  **/
package/src/dbHandler.js CHANGED
@@ -19,7 +19,7 @@ const registerEventQueueDbHandler = (dbService) => {
19
19
  registeredHandlers.eventQueueDbHandler = true;
20
20
  const def = dbService.model.definitions[config.tableNameEventQueue];
21
21
  dbService.after("CREATE", def, (_, req) => {
22
- if (req.tx._skipEventQueueBroadcase) {
22
+ if (req.tx._skipEventQueueBroadcast) {
23
23
  return;
24
24
  }
25
25
  req.tx._ = req.tx._ ?? {};
@@ -6,6 +6,7 @@ const EventQueueBaseClass = require("../EventQueueProcessorBase");
6
6
  const { EventProcessingStatus } = require("../constants");
7
7
  const common = require("../shared/common");
8
8
  const config = require("../config");
9
+ const EventQueueError = require("../EventQueueError");
9
10
 
10
11
  const COMPONENT_NAME = "/eventQueue/outbox/generic";
11
12
 
@@ -164,7 +165,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
164
165
  clusterData.queueEntries.map((entry) => entry.payload.data)
165
166
  );
166
167
  if (!clusterResult) {
167
- throw new Error("hmm??");
168
+ throw EventQueueError.invalidClusterHandlerResult(clustersKey, propertyName);
168
169
  }
169
170
  clusterData.payload.data = clusterResult;
170
171
  }
@@ -20,16 +20,6 @@ function outboxed(srv, customOpts) {
20
20
  }
21
21
 
22
22
  const logger = cds.log(COMPONENT_NAME);
23
- let outboxOpts = Object.assign(
24
- {},
25
- (typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
26
- (typeof cds.requires.queue === "object" && cds.requires.queue) || {},
27
- (typeof srv.options?.outbox === "object" && srv.options.outbox) || {},
28
- (typeof srv.options?.queued === "object" && srv.options.queued) || {},
29
- customOpts || {}
30
- );
31
-
32
- config.addCAPOutboxEventBase(srv.name, outboxOpts);
33
23
 
34
24
  const originalSrv = srv[UNBOXED] || srv;
35
25
  const outboxedSrv = Object.create(originalSrv);
@@ -42,25 +32,19 @@ function outboxed(srv, customOpts) {
42
32
  }
43
33
  outboxedSrv.handle = async function (req) {
44
34
  const context = req.context || cds.context;
45
- outboxOpts = Object.assign(
46
- {},
47
- (typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
48
- (typeof cds.requires.queue === "object" && cds.requires.queue) || {},
49
- (typeof srv.options?.outbox === "object" && srv.options.outbox) || {},
50
- (typeof srv.options?.queued === "object" && srv.options.queued) || {},
51
- customOpts || {}
52
- );
53
- config.addCAPOutboxEventBase(srv.name, outboxOpts);
54
35
  const hasSpecificSettings = !!config.getCdsOutboxEventSpecificConfig(srv.name, req.event);
55
- if (hasSpecificSettings) {
56
- outboxOpts = config.addCAPOutboxEventSpecificAction(srv.name, req.event);
57
- }
58
36
  const subType = hasSpecificSettings ? [srv.name, req.event].join(".") : srv.name;
59
- const namespace = outboxOpts.namespace ?? config.namespace;
60
- outboxOpts = config.getEventConfig(CDS_EVENT_TYPE, subType, namespace);
37
+ let srvConfig = config.findBaseCAPServiceWithoutNamespace(subType);
38
+ // NOTE: service is outboxed without config in cds.env.requires[srv]
39
+ if (!srvConfig) {
40
+ config.addCAPServiceWithoutEnvConfig(subType, srv, customOpts);
41
+ srvConfig = config.findBaseCAPServiceWithoutNamespace(subType);
42
+ }
43
+
44
+ const outboxOpts = config.getEventConfig(CDS_EVENT_TYPE, subType, srvConfig.namespace);
61
45
  const eventHeaders = getPropagatedHeaders(outboxOpts, req);
62
46
  if (["persistent-outbox", "persistent-queue"].includes(outboxOpts.kind)) {
63
- await _mapToEventAndPublish(context, subType, req, eventHeaders, namespace);
47
+ await _mapToEventAndPublish(context, subType, req, eventHeaders, srvConfig.namespace);
64
48
  return;
65
49
  }
66
50
  context.on("succeeded", async () => {
@@ -171,9 +171,9 @@ const _insertPeriodEvents = async (tx, events, now) => {
171
171
  counter++;
172
172
  });
173
173
 
174
- tx._skipEventQueueBroadcase = true;
174
+ tx._skipEventQueueBroadcast = true;
175
175
  await tx.run(INSERT.into(eventConfig.tableNameEventQueue).entries(eventsToBeInserted));
176
- tx._skipEventQueueBroadcase = false;
176
+ tx._skipEventQueueBroadcast = false;
177
177
  };
178
178
 
179
179
  const _generateKey = ({ type, subType, namespace }) => [namespace, type, subType].join("##");
@@ -137,6 +137,7 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
137
137
  eventTypeInstance.processEventContext = tx.context;
138
138
  const queueEntries = await eventTypeInstance.getQueueEntriesAndSetToInProgress();
139
139
  if (!queueEntries.length) {
140
+ processNext = false;
140
141
  return;
141
142
  }
142
143
  if (queueEntries.length > 1) {
@@ -77,17 +77,17 @@ const publishEvent = async (
77
77
  }
78
78
  }
79
79
  if (config.insertEventsBeforeCommit && !skipInsertEventsBeforeCommit) {
80
- _registerHandlerAndAddEvents(tx, events);
80
+ _registerHandlerAndAddEvents(tx, events, skipBroadcast);
81
81
  } else {
82
82
  let result;
83
- tx._skipEventQueueBroadcase = skipBroadcast;
83
+ tx._skipEventQueueBroadcast = skipBroadcast;
84
84
  result = await tx.run(INSERT.into(config.tableNameEventQueue).entries(events));
85
- tx._skipEventQueueBroadcase = false;
85
+ tx._skipEventQueueBroadcast = false;
86
86
  return result;
87
87
  }
88
88
  };
89
89
 
90
- const _registerHandlerAndAddEvents = (tx, events) => {
90
+ const _registerHandlerAndAddEvents = (tx, events, skipBroadcast) => {
91
91
  tx._eventQueue ??= { events: [], handlerRegistered: false };
92
92
  tx._eventQueue.events = tx._eventQueue.events.concat(events);
93
93
 
@@ -99,7 +99,9 @@ const _registerHandlerAndAddEvents = (tx, events) => {
99
99
  if (!tx._eventQueue.events?.length) {
100
100
  return;
101
101
  }
102
+ tx._skipEventQueueBroadcast = skipBroadcast;
102
103
  await tx.run(INSERT.into(config.tableNameEventQueue).entries(tx._eventQueue.events));
104
+ tx._skipEventQueueBroadcast = false;
103
105
  tx._eventQueue = null;
104
106
  });
105
107
  };
@@ -48,17 +48,20 @@ const _messageHandlerProcessEvents = async (messageData) => {
48
48
  if (config.isCapOutboxEvent(type)) {
49
49
  try {
50
50
  const service = await cds.connect.to(srvName);
51
- cds.outboxed(service);
52
- if (actionName) {
53
- const specificSettings = config.getCdsOutboxEventSpecificConfig(srvName, actionName);
54
- if (specificSettings) {
55
- config.addCAPOutboxEventSpecificAction(srvName, actionName);
56
- }
51
+ if (!service || actionName) {
52
+ logger.warn("could not find CAP Service configuration to process event!", {
53
+ type,
54
+ subType,
55
+ namespace,
56
+ });
57
+ return;
57
58
  }
59
+ config.addCAPServiceWithoutEnvConfig(subType, service);
58
60
  } catch (err) {
59
61
  logger.warn("could not connect to outboxed service", err, {
60
62
  type,
61
63
  subType,
64
+ namespace,
62
65
  });
63
66
  return;
64
67
  }
@@ -1,8 +1,5 @@
1
1
  "use strict";
2
2
 
3
- const cds = require("@sap/cds");
4
-
5
- const eventConfig = require("../config");
6
3
  const { EventProcessingStatus } = require("../constants");
7
4
  const config = require("../config");
8
5
 
@@ -10,9 +7,9 @@ const MS_IN_DAYS = 24 * 60 * 60 * 1000;
10
7
 
11
8
  const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
12
9
  const startTime = new Date();
13
- const refDateStartAfter = new Date(startTime.getTime() + eventConfig.runInterval * 1.2);
10
+ const refDateStartAfter = new Date(startTime.getTime() + config.runInterval * 1.2);
14
11
  const entries = await tx.run(
15
- SELECT.from(eventConfig.tableNameEventQueue)
12
+ SELECT.from(config.tableNameEventQueue)
16
13
  .where(
17
14
  "namespace IN",
18
15
  config.processingNamespaces,
@@ -25,7 +22,7 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
25
22
  ") OR ( status =",
26
23
  EventProcessingStatus.InProgress,
27
24
  "AND lastAttemptTimestamp <=",
28
- new Date(startTime.getTime() - eventConfig.globalTxTimeout).toISOString(),
25
+ new Date(startTime.getTime() - config.globalTxTimeout).toISOString(),
29
26
  ") ) AND (createdAt >=",
30
27
  new Date(startTime.getTime() - 30 * MS_IN_DAYS).toISOString(),
31
28
  " OR startAfter >=",
@@ -38,19 +35,19 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
38
35
 
39
36
  const result = [];
40
37
  for (const { type, subType, namespace } of entries) {
41
- if (eventConfig.isCapOutboxEvent(type)) {
38
+ if (config.isCapOutboxEvent(type)) {
42
39
  const { srvName, actionName } = config.normalizeSubType(type, subType);
43
40
  try {
44
- const service = await cds.connect.to(srvName);
45
41
  if (filterAppSpecificEvents) {
46
- if (!service) {
47
- continue;
48
- }
49
- cds.outboxed(service);
50
- if (actionName) {
51
- config.addCAPOutboxEventSpecificAction(srvName, actionName);
42
+ const eventConfig = config.getEventConfig(type, subType, namespace);
43
+ if (!eventConfig) {
44
+ const service = await cds.connect.to(srvName);
45
+ if (!service || actionName) {
46
+ continue;
47
+ }
48
+ config.addCAPServiceWithoutEnvConfig(subType, service);
52
49
  }
53
- if (eventConfig.shouldBeProcessedInThisApplication(type, subType, namespace)) {
50
+ if (config.shouldBeProcessedInThisApplication(type, subType, namespace)) {
54
51
  result.push({ namespace, type, subType });
55
52
  }
56
53
  }
@@ -64,8 +61,8 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
64
61
  } else {
65
62
  if (filterAppSpecificEvents) {
66
63
  if (
67
- eventConfig.getEventConfig(type, subType, namespace) &&
68
- eventConfig.shouldBeProcessedInThisApplication(type, subType, namespace)
64
+ config.getEventConfig(type, subType, namespace) &&
65
+ config.shouldBeProcessedInThisApplication(type, subType, namespace)
69
66
  ) {
70
67
  result.push({ namespace, type, subType });
71
68
  }
@@ -180,7 +180,7 @@ const _executeEventsAllTenantsRedis = async (tenantIds) => {
180
180
  }
181
181
  };
182
182
 
183
- const _executeEventsAllTenants = async (tenantIds, runId) => {
183
+ const _executeEventsAllTenants = async (tenantIds) => {
184
184
  const promises = [];
185
185
  for (const tenantId of tenantIds) {
186
186
  const id = cds.utils.uuid();
@@ -230,13 +230,6 @@ const _executeEventsAllTenants = async (tenantIds, runId) => {
230
230
  label,
231
231
  async () => {
232
232
  try {
233
- const lockId = `${runId}_${label}`;
234
- const couldAcquireLock = await distributedLock.acquireLock(context, lockId, {
235
- expiryTime: eventQueueConfig.runInterval * 0.95,
236
- });
237
- if (!couldAcquireLock) {
238
- return;
239
- }
240
233
  await runEventCombinationForTenant(
241
234
  context,
242
235
  eventConfig.type,
@@ -308,50 +301,40 @@ const _singleTenantDb = async () => {
308
301
  { newRootSpan: true }
309
302
  );
310
303
 
311
- return await Promise.allSettled(
312
- events.map(async (openEvent) => {
313
- const eventConfig = config.getEventConfig(openEvent.type, openEvent.subType, openEvent.namespace);
314
- const label = [openEvent.namespace, eventConfig.type, eventConfig.subType].join("##");
315
- return await WorkerQueue.instance.addToQueue(
316
- eventConfig.load,
317
- label,
318
- eventConfig.priority,
319
- eventConfig.increasePriorityOverTime,
320
- async () => {
321
- return await cds.tx({}, async ({ context }) => {
322
- await trace(
323
- context,
324
- label,
325
- async () => {
326
- try {
327
- const couldAcquireLock = eventConfig.multiInstanceProcessing
328
- ? true
329
- : await distributedLock.acquireLock(context, label, {
330
- expiryTime: eventQueueConfig.runInterval * 0.95,
331
- });
332
- if (!couldAcquireLock) {
333
- return;
304
+ for (const openEvent of events) {
305
+ const eventConfig = config.getEventConfig(openEvent.type, openEvent.subType, openEvent.namespace);
306
+ const label = [openEvent.namespace, eventConfig.type, eventConfig.subType].join("##");
307
+ await WorkerQueue.instance.addToQueue(
308
+ eventConfig.load,
309
+ label,
310
+ eventConfig.priority,
311
+ eventConfig.increasePriorityOverTime,
312
+ async () => {
313
+ return await cds.tx({}, async ({ context }) => {
314
+ await trace(
315
+ context,
316
+ label,
317
+ async () => {
318
+ try {
319
+ await runEventCombinationForTenant(
320
+ context,
321
+ eventConfig.type,
322
+ eventConfig.subType,
323
+ openEvent.namespace,
324
+ {
325
+ skipWorkerPool: true,
334
326
  }
335
- await runEventCombinationForTenant(
336
- context,
337
- eventConfig.type,
338
- eventConfig.subType,
339
- openEvent.namespace,
340
- {
341
- skipWorkerPool: true,
342
- }
343
- );
344
- } catch (err) {
345
- cds.log(COMPONENT_NAME).error("executing event-queue run for tenant failed");
346
- }
347
- },
348
- { newRootSpan: true }
349
- );
350
- });
351
- }
352
- );
353
- })
354
- );
327
+ );
328
+ } catch (err) {
329
+ cds.log(COMPONENT_NAME).error("executing event-queue run for tenant failed");
330
+ }
331
+ },
332
+ { newRootSpan: true }
333
+ );
334
+ });
335
+ }
336
+ );
337
+ }
355
338
  };
356
339
 
357
340
  const _singleTenantRedis = async () => {
@@ -467,7 +450,7 @@ const _multiTenancyDb = async () => {
467
450
  logger.info("executing event queue run for single instance and multi tenant");
468
451
  const tenantIds = await cdsHelper.getAllTenantIds();
469
452
  await _checkPeriodicEventUpdate(tenantIds);
470
- return await _executeEventsAllTenants(tenantIds, EVENT_QUEUE_RUN_ID);
453
+ return await _executeEventsAllTenants(tenantIds);
471
454
  } catch (err) {
472
455
  logger.error("Couldn't fetch tenant ids for event queue processing! Next try after defined interval.", err);
473
456
  }
@@ -4,7 +4,7 @@ const { AsyncResource } = require("async_hooks");
4
4
 
5
5
  const cds = require("@sap/cds");
6
6
 
7
- const { processEventQueue } = require("../processEventQueue");
7
+ const processor = require("../processEventQueue");
8
8
  const eventQueueConfig = require("../config");
9
9
  const WorkerQueue = require("../shared/WorkerQueue");
10
10
  const distributedLock = require("../shared/distributedLock");
@@ -21,7 +21,7 @@ const runEventCombinationForTenant = async (
21
21
  ) => {
22
22
  try {
23
23
  if (skipWorkerPool) {
24
- return await processEventQueue(context, type, subType, namespace);
24
+ return await processor.processEventQueue(context, type, subType, namespace);
25
25
  } else {
26
26
  const eventConfig = eventQueueConfig.getEventConfig(type, subType, namespace);
27
27
  const label = `${type}_${subType}`;
@@ -39,7 +39,7 @@ const runEventCombinationForTenant = async (
39
39
  }
40
40
  }
41
41
 
42
- await processEventQueue(context, type, subType, namespace);
42
+ await processor.processEventQueue(context, type, subType, namespace);
43
43
  };
44
44
  if (shouldTrace) {
45
45
  return await trace(context, label, _exec, { newRootSpan: true });
@@ -26,7 +26,7 @@ const CONCURRENCY_AUTH_INFO = 3;
26
26
  async function executeInNewTransaction(context = {}, transactionTag, fn, args, { info = {} } = {}) {
27
27
  const parameters = Array.isArray(args) ? args : [args];
28
28
  const logger = cds.log(COMPONENT_NAME);
29
- let transactionRollbackPromise = Promise.resolve(false);
29
+ let transactionRollbackPromise = Promise.resolve(true);
30
30
  try {
31
31
  const authInfo = await common.getAuthContext(context.tenant);
32
32
  const user = new cds.User.Privileged({ id: config.userId, authInfo, tokenInfo: authInfo?.token });
@@ -11,9 +11,9 @@ const COMPONENT_NAME = "/eventQueue/distributedLock";
11
11
  const acquireLock = async (
12
12
  context,
13
13
  key,
14
- { tenantScoped = true, expiryTime = config.globalTxTimeout, keepTrackOfLock = false } = {}
14
+ { tenantScoped = true, expiryTime = config.globalTxTimeout, keepTrackOfLock = false, skipNamespace = false } = {}
15
15
  ) => {
16
- const fullKey = _generateKey(context, tenantScoped, key);
16
+ const fullKey = _generateKey(context, tenantScoped, key, skipNamespace);
17
17
  if (config.redisEnabled) {
18
18
  return await _acquireLockRedis(context, fullKey, expiryTime, { keepTrackOfLock });
19
19
  } else {
@@ -51,8 +51,8 @@ const setValueWithExpire = async (
51
51
  }
52
52
  };
53
53
 
54
- const releaseLock = async (context, key, { tenantScoped = true } = {}) => {
55
- const fullKey = _generateKey(context, tenantScoped, key);
54
+ const releaseLock = async (context, key, { tenantScoped = true, skipNamespace = false } = {}) => {
55
+ const fullKey = _generateKey(context, tenantScoped, key, skipNamespace);
56
56
  if (config.redisEnabled) {
57
57
  return await _releaseLockRedis(context, fullKey);
58
58
  } else {
@@ -194,8 +194,8 @@ const _acquireLockDB = async (
194
194
  return result;
195
195
  };
196
196
 
197
- const _generateKey = (context, tenantScoped, key) => {
198
- const keyParts = [config.redisNamespace()];
197
+ const _generateKey = (context, tenantScoped, key, skipNamespace) => {
198
+ const keyParts = [config.redisNamespace(!skipNamespace)];
199
199
  tenantScoped && keyParts.push(context.tenant);
200
200
  keyParts.push(key);
201
201
  return `${keyParts.join("##")}`;
@@ -216,14 +216,15 @@ const getAllLocksRedis = async () => {
216
216
  // NOTE: use SCAN because KEYS is not supported for cluster clients
217
217
  for (const client of clients) {
218
218
  for await (const key of client.scanIterator({ MATCH: "EVENT*", COUNT: 1000 })) {
219
- const [, tenant, guidOrType, subType] = key.split("##");
219
+ const [, namespace, tenant, guidOrType, subType] = key.split("##");
220
220
  if (!subType) {
221
221
  continue;
222
222
  }
223
223
 
224
224
  const pipeline = client.multi();
225
225
  output.push({
226
- tenant: tenant,
226
+ namespace,
227
+ tenant,
227
228
  type: guidOrType,
228
229
  subType: subType,
229
230
  });
@@ -9,14 +9,9 @@ service EventQueueAdminService {
9
9
  @cds.persistence.skip
10
10
  entity Event as projection on db.Event {
11
11
  null as tenant: String,
12
- null as landscape: String,
13
- null as space: String,
14
12
  *
15
13
  } actions {
16
14
  action setStatusAndAttempts(
17
- // TODO: remove tenant as soon as CAP issue is fixed https://github.tools.sap/cap/issues/issues/18445
18
- @mandatory
19
- tenant: String,
20
15
  status: db.Status,
21
16
  @assert.range: [0,100]
22
17
  attempts: Integer) returns Event;
@@ -25,18 +20,14 @@ service EventQueueAdminService {
25
20
  @cds.persistence.skip
26
21
  @readonly
27
22
  entity Lock {
23
+ key namespace: String;
28
24
  key tenant: String;
29
25
  key type: String;
30
26
  key subType: String;
31
- landscape: String;
32
- space: String;
33
27
  ttl: Integer;
34
28
  createdAt: Integer;
35
29
  } actions {
36
30
  action releaseLock(
37
- // TODO: remove tenant as soon as CAP issue is fixed https://github.tools.sap/cap/issues/issues/18445
38
- @mandatory
39
- tenant: String,
40
31
  @mandatory
41
32
  type: String,
42
33
  @mandatory
@@ -44,6 +35,8 @@ service EventQueueAdminService {
44
35
  }
45
36
 
46
37
  action publishEvent(
38
+ @mandatory
39
+ namespace: String,
47
40
  @mandatory
48
41
  tenants: array of String,
49
42
  @mandatory
@@ -52,7 +45,7 @@ service EventQueueAdminService {
52
45
  subType: String,
53
46
  referenceEntity: String,
54
47
  referenceEntityKey: String,
55
- @open
56
- payload: {},
57
- startAfter: String);
48
+ payload: String,
49
+ startAfter: String,
50
+ );
58
51
  }
@@ -15,7 +15,6 @@ module.exports = class AdminService extends cds.ApplicationService {
15
15
  const { Event: EventService, Lock: LockService } = this.entities;
16
16
  const { Event: EventDb } = cds.db.entities("sap.eventqueue");
17
17
  const { publishEvent } = this.actions;
18
- const { landscape, space } = this.getLandscapeAndSpace();
19
18
 
20
19
  this.before("*", (req) => {
21
20
  if (!eventQueue.config.enableAdminService) {
@@ -26,6 +25,10 @@ module.exports = class AdminService extends cds.ApplicationService {
26
25
  return;
27
26
  }
28
27
 
28
+ if (req.target === LockService) {
29
+ return;
30
+ }
31
+
29
32
  const headers = Object.assign({}, req.headers, req.req?.headers);
30
33
  const tenant = headers["z-id"] ?? req.data.tenant;
31
34
 
@@ -46,8 +49,6 @@ module.exports = class AdminService extends cds.ApplicationService {
46
49
  }
47
50
  const events = await tx.run(req.query);
48
51
  return events?.map((event) => {
49
- event.landscape = landscape;
50
- event.space = space;
51
52
  event.tenant = tenant;
52
53
  return event;
53
54
  });
@@ -58,12 +59,7 @@ module.exports = class AdminService extends cds.ApplicationService {
58
59
  if (!eventQueue.config.redisEnabled) {
59
60
  return [];
60
61
  }
61
- const locks = await distributedLock.getAllLocksRedis();
62
- return locks.map((lock) => ({
63
- ...lock,
64
- landscape: landscape,
65
- space: space,
66
- }));
62
+ return await distributedLock.getAllLocksRedis();
67
63
  });
68
64
 
69
65
  this.on("setStatusAndAttempts", async (req) => {
@@ -97,17 +93,20 @@ module.exports = class AdminService extends cds.ApplicationService {
97
93
  });
98
94
 
99
95
  this.on("releaseLock", async (req) => {
96
+ const tenant = req.headers["z-id"];
100
97
  cds.log("eventQueue").info("Releasing event-queue lock", req.data);
101
- const { tenant, type, subType } = req.data;
98
+ const { type, subType, namespace } = req.data;
102
99
  return await cds.tx({ tenant }, async (tx) => {
103
- return await distributedLock.releaseLock(tx.context, [type, subType].join("##"));
100
+ return await distributedLock.releaseLock(tx.context, [namespace, type, subType].join("##"), {
101
+ skipNamespace: true,
102
+ });
104
103
  });
105
104
  });
106
105
 
107
106
  this.on(publishEvent, async (req) => {
108
107
  const logger = cds.log(COMPONENT_NAME);
109
108
  try {
110
- const { type, subType, referenceEntity, referenceEntityKey, payload, startAfter } = req.data;
109
+ const { type, subType, referenceEntity, referenceEntityKey, payload, startAfter, namespace } = req.data;
111
110
  const tenants = await publishEventHelper.resolveTenantInfos(req);
112
111
  const eventOptions = commonHelper.cleanUndefined({
113
112
  type,
@@ -116,8 +115,9 @@ module.exports = class AdminService extends cds.ApplicationService {
116
115
  referenceEntityKey,
117
116
  payload,
118
117
  startAfter,
118
+ namespace,
119
119
  });
120
- const publishInfo = { count: tenants.length, type, subType, tenants: req.data.tenants };
120
+ const publishInfo = { count: tenants.length, type, subType, namespace, tenants: req.data.tenants };
121
121
  logger.info("publishing event for tenant(s)", publishInfo);
122
122
  for (const tenant of tenants) {
123
123
  await cds.tx({ tenant }, async (tx) => {
@@ -127,25 +127,10 @@ module.exports = class AdminService extends cds.ApplicationService {
127
127
  logger.info("finished publishing event for tenant(s)", publishInfo);
128
128
  } catch (err) {
129
129
  logger.error("error publishing event", err);
130
+ req.reject(400, "Error publishing event: " + err.toString());
130
131
  }
131
132
  });
132
133
 
133
134
  await super.init();
134
135
  }
135
-
136
- getLandscapeAndSpace() {
137
- const url = cds.requires.db.credentials.url;
138
- if (!url) {
139
- return { landscape: "eu10-canary", space: "local-dev" };
140
- }
141
- const match = url.match(/https?:\/\/[^.]+\.authentication\.([^.]+)\.hana\.ondemand\.com/);
142
- const landscape = (match?.[1] ?? "sap") === "sap" ? "eu10-canary" : match?.[1];
143
- let space = "local-dev";
144
- try {
145
- space = JSON.parse(process.env.VCAP_APPLICATION)?.space_name;
146
- } catch {
147
- /* empty */
148
- }
149
- return { landscape, space };
150
- }
151
136
  };