@cap-js-community/event-queue 2.0.0-beta.3 → 2.0.0-beta.5

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-beta.3",
3
+ "version": "2.0.0-beta.5",
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",
@@ -24,7 +24,8 @@ const SELECT_LIMIT_EVENTS_PER_TICK = 100;
24
24
  const TRIES_FOR_EXCEEDED_EVENTS = 3;
25
25
  const EVENT_START_AFTER_HEADROOM = 3 * 1000;
26
26
  const SUFFIX_PERIODIC = "_PERIODIC";
27
- const DEFAULT_RETRY_AFTER = 5 * 60 * 1000;
27
+
28
+ const ALLOWED_FIELDS_FOR_UPDATE = ["status", "startAfter", "error"];
28
29
 
29
30
  class EventQueueProcessorBase {
30
31
  #eventsWithExceededTries = [];
@@ -38,7 +39,6 @@ class EventQueueProcessorBase {
38
39
  #eventConfig;
39
40
  #isPeriodic;
40
41
  #lastSuccessfulRunTimestamp;
41
- #retryFailedAfter;
42
42
  #keepAliveRunner;
43
43
  #currentKeepAlivePromise = Promise.resolve();
44
44
  #etagMap;
@@ -66,7 +66,6 @@ class EventQueueProcessorBase {
66
66
  if (this.__parallelEventProcessing > LIMIT_PARALLEL_EVENT_PROCESSING) {
67
67
  this.__parallelEventProcessing = LIMIT_PARALLEL_EVENT_PROCESSING;
68
68
  }
69
- this.#retryFailedAfter = this.#eventConfig.retryFailedAfter ?? DEFAULT_RETRY_AFTER;
70
69
  this.__concurrentEventProcessing = this.#eventConfig.multiInstanceProcessing;
71
70
  this.__retryAttempts = this.#isPeriodic ? 1 : this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS;
72
71
  this.__selectMaxChunkSize = this.#eventConfig.selectMaxChunkSize ?? SELECT_LIMIT_EVENTS_PER_TICK;
@@ -345,17 +344,21 @@ class EventQueueProcessorBase {
345
344
  }
346
345
  }
347
346
 
348
- #determineAndAddEventStatusToMap(id, processingStatus, statusMap = this.__statusMap) {
347
+ #determineAndAddEventStatusToMap(id, statusOrUpdateData, statusMap = this.__statusMap) {
348
+ if (typeof statusOrUpdateData === "number") {
349
+ statusOrUpdateData = { status: statusOrUpdateData };
350
+ }
351
+
349
352
  if (!statusMap[id]) {
350
- statusMap[id] = processingStatus;
353
+ statusMap[id] = statusOrUpdateData;
351
354
  return;
352
355
  }
353
- if ([EventProcessingStatus.Error, EventProcessingStatus.Exceeded].includes(statusMap[id])) {
356
+ if ([EventProcessingStatus.Error, EventProcessingStatus.Exceeded].includes(statusMap[id].status)) {
354
357
  // NOTE: worst aggregation --> if already error|exceeded keep this state
355
358
  return;
356
359
  }
357
- if (statusMap[id] >= 0) {
358
- statusMap[id] = processingStatus;
360
+ if (statusMap[id].status >= 0) {
361
+ statusMap[id] = { status: statusOrUpdateData };
359
362
  }
360
363
  }
361
364
 
@@ -396,6 +399,25 @@ class EventQueueProcessorBase {
396
399
  );
397
400
  }
398
401
 
402
+ #normalizeStatusMap(statusMap) {
403
+ const originalMap = {};
404
+ for (const [id, entry] of Object.entries(statusMap)) {
405
+ originalMap[id] = entry;
406
+ const result = {};
407
+ if (typeof entry === "number") {
408
+ result.status = entry;
409
+ } else if (typeof entry === "object") {
410
+ for (const fieldName of ALLOWED_FIELDS_FOR_UPDATE) {
411
+ if (fieldName in entry) {
412
+ result[fieldName] = entry[fieldName];
413
+ }
414
+ }
415
+ }
416
+ statusMap[id] = result;
417
+ }
418
+ return originalMap;
419
+ }
420
+
399
421
  /**
400
422
  * This function validates for all selected events one status has been submitted. It's also validated that only for
401
423
  * selected events a status has been submitted. Persisting the status of events is done in a dedicated database tx.
@@ -406,98 +428,123 @@ class EventQueueProcessorBase {
406
428
  eventType: this.#eventType,
407
429
  eventSubType: this.#eventSubType,
408
430
  });
431
+ const originalStatusMap = this.#normalizeStatusMap(statusMap);
409
432
  this.#ensureOnlySelectedQueueEntries(statusMap);
410
433
  if (!skipChecks) {
411
434
  this.#ensureEveryQueueEntryHasStatus();
412
435
  }
413
436
  this.#ensureEveryStatusIsAllowed(statusMap);
414
-
415
- const { success, failed, exceeded, invalidAttempts } = Object.entries(statusMap).reduce(
416
- (result, [queueEntryId, processingStatus]) => {
417
- this.__commitedStatusMap[queueEntryId] = processingStatus;
418
- delete this.__notCommitedStatusMap[queueEntryId];
419
- if (processingStatus === EventProcessingStatus.Open) {
420
- result.invalidAttempts.push(queueEntryId);
421
- } else if (processingStatus === EventProcessingStatus.Done) {
422
- result.success.push(queueEntryId);
423
- } else if (processingStatus === EventProcessingStatus.Error) {
424
- result.failed.push(queueEntryId);
425
- } else if (processingStatus === EventProcessingStatus.Exceeded) {
426
- result.exceeded.push(queueEntryId);
427
- }
428
- return result;
429
- },
430
- {
431
- success: [],
432
- failed: [],
433
- exceeded: [],
434
- invalidAttempts: [],
435
- }
436
- );
437
- if (![success, failed, exceeded, invalidAttempts].some((statusArray) => statusArray.length)) {
438
- this.logger.debug("exiting persistEventStatus", {
439
- eventType: this.#eventType,
440
- eventSubType: this.#eventSubType,
441
- });
442
- return;
443
- }
444
-
445
437
  return await trace(this.baseContext, "persist-event-status", async () => {
446
438
  this.logger.debug("persistEventStatus for entries", {
447
439
  eventType: this.#eventType,
448
440
  eventSubType: this.#eventSubType,
449
- invalidAttempts,
450
- failed,
451
- exceeded,
452
- success,
441
+ statusMap,
453
442
  });
454
- if (invalidAttempts.length) {
455
- await tx.run(
456
- UPDATE.entity(this.#config.tableNameEventQueue)
457
- .set({
458
- status: EventProcessingStatus.Open,
459
- lastAttemptTimestamp: new Date().toISOString(),
460
- attempts: { "-=": 1 },
461
- })
462
- .where("ID IN", invalidAttempts)
463
- );
464
- }
465
443
  const ts = new Date().toISOString();
466
- const updateTuples = [
467
- [success, EventProcessingStatus.Done],
468
- [failed, EventProcessingStatus.Error],
469
- [exceeded, EventProcessingStatus.Exceeded],
470
- ];
471
-
472
- for (const [eventIds, status] of updateTuples) {
473
- if (!eventIds.length) {
444
+ const updateData = Object.entries(statusMap).reduce((result, [id, data]) => {
445
+ const key = ALLOWED_FIELDS_FOR_UPDATE.map((name) => [name, data[name]])
446
+ .flat()
447
+ .join("##");
448
+
449
+ result[key] ??= { data, ids: [] };
450
+ result[key].ids.push(id);
451
+ return result;
452
+ }, {});
453
+
454
+ for (const { ids, data } of Object.values(updateData)) {
455
+ if (!("status" in data)) {
456
+ // TODO: Can this still happen?
457
+ this.logger.error("can't find status value in return value of event-processing. Setting event to done", {
458
+ ids,
459
+ // NOTE: use first id as same return values are clustered
460
+ eventReturnValue: originalStatusMap[ids[0]],
461
+ });
474
462
  continue;
475
463
  }
476
- let startAfter;
477
- if (status === EventProcessingStatus.Error) {
478
- startAfter = new Date(Date.now() + this.#retryFailedAfter);
464
+
465
+ for (const id of ids) {
466
+ this.__commitedStatusMap[id] = data.status;
467
+ delete this.__notCommitedStatusMap[id];
468
+ }
469
+
470
+ if (![EventProcessingStatus.Open, EventProcessingStatus.Error].includes(data.status)) {
471
+ delete data.startAfter;
472
+ }
473
+
474
+ if (data.startAfter) {
475
+ data.startAfter = this.#normalizeDate(data.startAfter);
476
+ }
477
+
478
+ if (data.error) {
479
+ data.error = this.#error2String(data.error);
480
+ }
481
+
482
+ if (!data.startAfter && [EventProcessingStatus.Error, EventProcessingStatus.Open].includes(data.status)) {
483
+ data.startAfter = new Date(
484
+ Date.now() +
485
+ (EventProcessingStatus.Error ? this.#eventConfig.retryFailedAfter : this.#eventConfig.retryOpenAfter)
486
+ );
487
+ }
488
+
489
+ if (data.startAfter) {
479
490
  this.#eventSchedulerInstance.scheduleEvent(
480
491
  this.__context.tenant,
481
492
  this.#eventType,
482
493
  this.#eventSubType,
483
494
  this.#namespace,
484
- startAfter
495
+ data.startAfter
485
496
  );
486
497
  }
487
498
 
488
499
  await tx.run(
489
500
  UPDATE.entity(this.#config.tableNameEventQueue)
490
501
  .set({
491
- status: status,
502
+ ...data,
492
503
  lastAttemptTimestamp: ts,
493
- ...(status === EventProcessingStatus.Error ? { startAfter: startAfter.toISOString() } : {}),
494
504
  })
495
- .where("ID IN", eventIds)
505
+ .where("ID IN", ids)
496
506
  );
497
507
  }
498
508
  });
499
509
  }
500
510
 
511
+ #error2String(error) {
512
+ return JSON.stringify(error, (_, value) => this.#errorReplacer(value));
513
+ }
514
+
515
+ #errorReplacer(value) {
516
+ if (!(value instanceof Error)) {
517
+ return value;
518
+ }
519
+
520
+ const plain = {
521
+ name: value.name,
522
+ message: value.message,
523
+ stack: value.stack,
524
+ };
525
+
526
+ for (const key in value) {
527
+ plain[key] = value[key];
528
+ }
529
+
530
+ return plain;
531
+ }
532
+
533
+ #normalizeDate(value) {
534
+ if (value instanceof Date) {
535
+ return value;
536
+ }
537
+
538
+ if (typeof value === "string" || typeof value === "number") {
539
+ const date = new Date(value);
540
+ if (!isNaN(date)) {
541
+ return date;
542
+ }
543
+ }
544
+
545
+ return null;
546
+ }
547
+
501
548
  #ensureEveryQueueEntryHasStatus() {
502
549
  this.__queueEntries.forEach((queueEntry) => {
503
550
  if (
@@ -507,17 +554,17 @@ class EventQueueProcessorBase {
507
554
  ) {
508
555
  return;
509
556
  }
510
- this.logger.error("Missing status for selected event entry. Setting status to error", {
557
+ this.logger.error("Missing status for selected event entry. Setting status to done", {
511
558
  eventType: this.#eventType,
512
559
  eventSubType: this.#eventSubType,
513
560
  queueEntry,
514
561
  });
515
- this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error);
562
+ this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Done);
516
563
  });
517
564
  }
518
565
 
519
566
  #ensureEveryStatusIsAllowed(statusMap) {
520
- Object.entries(statusMap).forEach(([queueEntryId, status]) => {
567
+ Object.entries(statusMap).forEach(([queueEntryId, { status }]) => {
521
568
  if (
522
569
  [
523
570
  EventProcessingStatus.Open,
@@ -533,7 +580,7 @@ class EventQueueProcessorBase {
533
580
  eventType: this.#eventType,
534
581
  eventSubType: this.#eventSubType,
535
582
  queueEntryId,
536
- status: statusMap[queueEntryId],
583
+ status: statusMap[queueEntryId].status,
537
584
  });
538
585
  delete statusMap[queueEntryId];
539
586
  });
package/src/config.js CHANGED
@@ -24,6 +24,7 @@ const CAP_EVENT_TYPE = "CAP_OUTBOX";
24
24
  const CAP_PARALLEL_DEFAULT = 5;
25
25
  const CAP_MAX_ATTEMPTS_DEFAULT = 5;
26
26
  const DELETE_TENANT_BLOCK_AFTER_MS = 5 * 60 * 1000;
27
+ const DEFAULT_RETRY_AFTER = 5 * 60 * 1000;
27
28
  const PRIORITIES = Object.values(Priorities);
28
29
  const UTC_DEFAULT = false;
29
30
  const USE_CRON_TZ_DEFAULT = true;
@@ -60,6 +61,8 @@ const ALLOWED_EVENT_OPTIONS_AD_HOC = [
60
61
  "processAfterCommit",
61
62
  "checkForNextChunk",
62
63
  "retryFailedAfter",
64
+ "propagateHeaders",
65
+ "retryOpenAfter",
63
66
  "multiInstanceProcessing",
64
67
  "kind",
65
68
  "timeBucket",
@@ -435,6 +438,9 @@ class Config {
435
438
  event._appInstancesMap = event.appInstances
436
439
  ? Object.fromEntries(new Map(event.appInstances.map((a) => [a, true])))
437
440
  : null;
441
+ event.propagateHeaders = event.propagateHeaders ?? [];
442
+ event.retryFailedAfter = event.retryFailedAfter ?? DEFAULT_RETRY_AFTER;
443
+ event.retryOpenAfter = event.retryOpenAfter ?? DEFAULT_RETRY_AFTER;
438
444
  }
439
445
 
440
446
  #basicEventValidation(event) {
@@ -751,6 +757,9 @@ class Config {
751
757
  }
752
758
 
753
759
  set useAsCAPOutbox(value) {
760
+ if (this.#useAsCAPOutbox) {
761
+ return;
762
+ }
754
763
  this.#useAsCAPOutbox = value;
755
764
  }
756
765
 
@@ -758,6 +767,14 @@ class Config {
758
767
  return this.#useAsCAPOutbox;
759
768
  }
760
769
 
770
+ set useAsCAPQueue(value) {
771
+ this.useAsCAPOutbox = value;
772
+ }
773
+
774
+ get useAsCAPQueue() {
775
+ return this.useAsCAPOutbox;
776
+ }
777
+
761
778
  set userId(value) {
762
779
  this.#userId = value;
763
780
  }
package/src/initialize.js CHANGED
@@ -35,6 +35,7 @@ const CONFIG_VARS = [
35
35
  ["updatePeriodicEvents", true],
36
36
  ["thresholdLoggingEventProcessing", 50],
37
37
  ["useAsCAPOutbox", false],
38
+ ["useAsCAPQueue", false],
38
39
  ["userId", null],
39
40
  ["cleanupLocksAndEventsForDev", false],
40
41
  ["redisOptions", {}],
@@ -65,6 +66,7 @@ const CONFIG_VARS = [
65
66
  * @param {boolean} [options.updatePeriodicEvents=true] - Automatically update periodic events.
66
67
  * @param {number} [options.thresholdLoggingEventProcessing=50] - Threshold for logging event processing time (in milliseconds).
67
68
  * @param {boolean} [options.useAsCAPOutbox=false] - Use the event queue as a CAP Outbox.
69
+ * @param {boolean} [options.useAsCAPQueue=false] - Use the event queue as a CAP Outbox.
68
70
  * @param {string} [options.userId=null] - ID of the user initiating the process.
69
71
  * @param {boolean} [options.cleanupLocksAndEventsForDev=false] - Cleanup locks and events for development environments.
70
72
  * @param {Object} [options.redisOptions={}] - Configuration options for Redis.
@@ -348,7 +348,13 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
348
348
  this.logger.error("error processing outboxed service call", err, {
349
349
  serviceName: this.eventSubType,
350
350
  });
351
- return queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Error]);
351
+ return queueEntries.map((queueEntry) => [
352
+ queueEntry.ID,
353
+ {
354
+ status: EventProcessingStatus.Error,
355
+ error: err,
356
+ },
357
+ ]);
352
358
  }
353
359
  }
354
360
 
@@ -359,10 +365,48 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
359
365
  return queueEntries.map((queueEntry) => [queueEntry.ID, result]);
360
366
  }
361
367
 
368
+ if (result instanceof Object && !Array.isArray(result)) {
369
+ return queueEntries.map((queueEntry) => [queueEntry.ID, result]);
370
+ }
371
+
362
372
  if (!Array.isArray(result)) {
363
373
  return queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Done]);
364
374
  }
365
375
 
376
+ const [firstEntry] = result;
377
+ if (Array.isArray(firstEntry)) {
378
+ const [, innerResult] = firstEntry;
379
+ if (innerResult instanceof Object) {
380
+ return result;
381
+ } else {
382
+ return result.map(([id, status]) => {
383
+ return [id, { status }];
384
+ });
385
+ }
386
+ } else if (firstEntry instanceof Object) {
387
+ return result.reduce((result, entry) => {
388
+ let { ID } = entry;
389
+
390
+ if (!ID) {
391
+ if (queueEntries.length > 1) {
392
+ throw new Error(
393
+ "The CAP handler return value does not match the event-queue specification. Please check the documentation"
394
+ );
395
+ } else {
396
+ ID = queueEntries[0].ID;
397
+ }
398
+ }
399
+
400
+ delete entry.ID;
401
+ if (!("status" in entry)) {
402
+ entry.status = EventProcessingStatus.Done;
403
+ }
404
+
405
+ result.push([ID, entry]);
406
+ return result;
407
+ }, []);
408
+ }
409
+
366
410
  const valid = !result.some((entry) => {
367
411
  const [, status] = entry;
368
412
  return !validStatusValues.includes(status);
@@ -51,14 +51,16 @@ function outboxed(srv, customOpts) {
51
51
  customOpts || {}
52
52
  );
53
53
  config.addCAPOutboxEventBase(srv.name, outboxOpts);
54
- const specificSettings = config.getCdsOutboxEventSpecificConfig(srv.name, req.event);
55
- if (specificSettings) {
54
+ const hasSpecificSettings = !!config.getCdsOutboxEventSpecificConfig(srv.name, req.event);
55
+ if (hasSpecificSettings) {
56
56
  outboxOpts = config.addCAPOutboxEventSpecificAction(srv.name, req.event);
57
57
  }
58
-
59
- const namespace = (specificSettings ?? outboxOpts).namespace ?? config.namespace;
58
+ 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);
61
+ const eventHeaders = getPropagatedHeaders(outboxOpts, req);
60
62
  if (["persistent-outbox", "persistent-queue"].includes(outboxOpts.kind)) {
61
- await _mapToEventAndPublish(context, srv.name, req, !!specificSettings, namespace);
63
+ await _mapToEventAndPublish(context, subType, req, eventHeaders, namespace);
62
64
  return;
63
65
  }
64
66
  context.on("succeeded", async () => {
@@ -84,24 +86,35 @@ function unboxed(srv) {
84
86
  return srv[UNBOXED] || srv;
85
87
  }
86
88
 
87
- const _mapToEventAndPublish = async (context, name, req, actionSpecific, namespace) => {
89
+ const getPropagatedHeaders = (config, req) => {
90
+ const propagateHeaders = config.propagateHeaders.reduce((headers, headerName) => {
91
+ if (headerName in req.tx.context.headers) {
92
+ headers[headerName] = req.tx.context.headers[headerName];
93
+ }
94
+ return headers;
95
+ }, {});
96
+ return Object.assign(propagateHeaders, req.headers);
97
+ };
98
+
99
+ const _mapToEventAndPublish = async (context, subType, req, eventHeaders, namespace) => {
88
100
  const eventQueueSpecificValues = {};
89
101
  for (const header in req.headers ?? {}) {
90
102
  for (const field of EVENT_QUEUE_SPECIFIC_FIELDS) {
91
103
  if (header.toLocaleLowerCase() === `x-eventqueue-${field.toLocaleLowerCase()}`) {
92
104
  eventQueueSpecificValues[field] = req.headers[header];
93
- delete req.headers[header];
105
+ delete eventHeaders[header];
94
106
  break;
95
107
  }
96
108
  }
97
109
  }
110
+
98
111
  const event = {
99
112
  contextUser: context.user.id,
100
113
  ...(req._fromSend || (req.reply && { _fromSend: true })), // send or emit
101
114
  ...(req.inbound && { inbound: req.inbound }),
102
115
  ...(req.event && { event: req.event }),
103
116
  ...(req.data && { data: req.data }),
104
- ...(req.headers && { headers: req.headers }),
117
+ ...(eventHeaders && { headers: eventHeaders }),
105
118
  ...(req.query && { query: req.query }),
106
119
  };
107
120
 
@@ -109,7 +122,7 @@ const _mapToEventAndPublish = async (context, name, req, actionSpecific, namespa
109
122
  cds.tx(context),
110
123
  {
111
124
  type: CDS_EVENT_TYPE,
112
- subType: actionSpecific ? [name, req.event].join(".") : name,
125
+ subType,
113
126
  payload: JSON.stringify(event),
114
127
  namespace: eventQueueSpecificValues.namespace ?? namespace,
115
128
  ...eventQueueSpecificValues,
@@ -17,9 +17,9 @@ const subscribeRedisChannel = async (channel, subscribeHandler) => {
17
17
  return await redisClient.subscribeChannel(config.redisOptions, channelWithNamespace, subscribeHandler);
18
18
  };
19
19
 
20
- const publishMessage = async (channel, message, { addNamespace = true } = {}) => {
20
+ const publishMessage = async (channel, message) => {
21
21
  const redisClient = RedisClient.create(REDIS_CLIENT_NAME);
22
- const channelWithNamespace = [config.redisNamespace(addNamespace), channel].join("##");
22
+ const channelWithNamespace = [config.redisNamespace(false), channel].join("##");
23
23
  return await redisClient.publishMessage(config.redisOptions, channelWithNamespace, message);
24
24
  };
25
25
 
@@ -27,11 +27,9 @@ const attachRedisUnsubscribeHandler = () => {
27
27
 
28
28
  const handleUnsubscribe = (tenantId) => {
29
29
  if (config.redisEnabled) {
30
- client
31
- .publishMessage(REDIS_OFFBOARD_TENANT_CHANNEL, JSON.stringify({ tenantId }), { addNamespace: false })
32
- .catch((error) => {
33
- cds.log(COMPONENT_NAME).error(`publishing tenant unsubscribe failed. tenantId: ${tenantId}`, error);
34
- });
30
+ client.publishMessage(REDIS_OFFBOARD_TENANT_CHANNEL, JSON.stringify({ tenantId })).catch((error) => {
31
+ cds.log(COMPONENT_NAME).error(`publishing tenant unsubscribe failed. tenantId: ${tenantId}`, error);
32
+ });
35
33
  } else {
36
34
  config.executeUnsubscribeHandlers(tenantId);
37
35
  }