@cap-js-community/event-queue 2.0.4 → 2.1.0-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "2.0.4",
3
+ "version": "2.1.0-beta.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",
@@ -56,17 +56,16 @@
56
56
  "devDependencies": {
57
57
  "@actions/core": "^2.0.2",
58
58
  "@cap-js/cds-test": "^0.4.1",
59
- "@cap-js/db-service": "^2.8.1",
60
- "@cap-js/hana": "^2.5.1",
61
- "@cap-js/sqlite": "^2.1.2",
59
+ "@cap-js/db-service": "^2.8.2",
60
+ "@cap-js/hana": "^2.6.0",
61
+ "@cap-js/sqlite": "^2.1.3",
62
62
  "@opentelemetry/api": "^1.9.0",
63
- "@sap/cds": "^9.6.4",
64
- "@sap/cds-dk": "^9.6.1",
63
+ "@sap/cds": "^9.7.0",
64
+ "@sap/cds-dk": "^9.7.0",
65
65
  "eslint": "^8.57.1",
66
66
  "eslint-config-prettier": "^10.1.8",
67
67
  "eslint-plugin-jest": "^29.12.1",
68
68
  "eslint-plugin-node": "^11.1.0",
69
- "express": "^4.22.1",
70
69
  "jest": "^29.7.0",
71
70
  "prettier": "^2.8.8"
72
71
  },
@@ -25,7 +25,7 @@ const TRIES_FOR_EXCEEDED_EVENTS = 3;
25
25
  const EVENT_START_AFTER_HEADROOM = 3 * 1000;
26
26
  const SUFFIX_PERIODIC = "_PERIODIC";
27
27
 
28
- const ALLOWED_FIELDS_FOR_UPDATE = ["status", "startAfter", "error"];
28
+ const ALLOWED_FIELDS_FOR_UPDATE = ["status", "startAfter", "error", "nextData"];
29
29
 
30
30
  class EventQueueProcessorBase {
31
31
  #eventsWithExceededTries = [];
@@ -43,6 +43,7 @@ class EventQueueProcessorBase {
43
43
  #currentKeepAlivePromise = Promise.resolve();
44
44
  #etagMap;
45
45
  #namespace;
46
+ #nextSagaEvents;
46
47
 
47
48
  constructor(context, eventType, eventSubType, config) {
48
49
  this.__context = context;
@@ -468,6 +469,10 @@ class EventQueueProcessorBase {
468
469
  return result;
469
470
  }, {});
470
471
 
472
+ if (!Object.values(updateData).length) {
473
+ return;
474
+ }
475
+
471
476
  for (const { ids, data } of Object.values(updateData)) {
472
477
  if (!("status" in data)) {
473
478
  // TODO: Can this still happen?
@@ -522,6 +527,11 @@ class EventQueueProcessorBase {
522
527
  .where("ID IN", ids)
523
528
  );
524
529
  }
530
+
531
+ if (this.#nextSagaEvents?.length) {
532
+ await tx.run(INSERT.into(this.#config.tableNameEventQueue).entries(this.#nextSagaEvents));
533
+ this.#nextSagaEvents = [];
534
+ }
525
535
  });
526
536
  }
527
537
 
@@ -1390,6 +1400,10 @@ class EventQueueProcessorBase {
1390
1400
  get allowedFieldsEventHandler() {
1391
1401
  return ALLOWED_FIELDS_FOR_UPDATE;
1392
1402
  }
1403
+
1404
+ set nextSagaEvents(value) {
1405
+ this.#nextSagaEvents = value;
1406
+ }
1393
1407
  }
1394
1408
 
1395
1409
  module.exports = EventQueueProcessorBase;
package/src/config.js CHANGED
@@ -28,6 +28,8 @@ const DEFAULT_RETRY_AFTER = 5 * 60 * 1000;
28
28
  const PRIORITIES = Object.values(Priorities);
29
29
  const UTC_DEFAULT = false;
30
30
  const USE_CRON_TZ_DEFAULT = true;
31
+ const SAGA_SUCCESS = "#succeeded";
32
+ const SAGA_FAILED = "#failed";
31
33
 
32
34
  const BASE_TABLES = {
33
35
  EVENT: "sap.eventqueue.Event",
@@ -62,6 +64,7 @@ const ALLOWED_EVENT_OPTIONS_AD_HOC = [
62
64
  "checkForNextChunk",
63
65
  "retryFailedAfter",
64
66
  "propagateHeaders",
67
+ "propagatedContextProperties",
65
68
  "retryOpenAfter",
66
69
  "multiInstanceProcessing",
67
70
  "kind",
@@ -236,7 +239,7 @@ class Config {
236
239
 
237
240
  executeUnsubscribeHandlers(tenantId) {
238
241
  this.#unsubscribedTenants[tenantId] = true;
239
- setTimeout(() => delete this.#unsubscribedTenants[tenantId], DELETE_TENANT_BLOCK_AFTER_MS);
242
+ setTimeout(() => delete this.#unsubscribedTenants[tenantId], DELETE_TENANT_BLOCK_AFTER_MS).unref();
240
243
  for (const unsubscribeHandler of this.#unsubscribeHandlers) {
241
244
  try {
242
245
  unsubscribeHandler(tenantId);
@@ -383,6 +386,20 @@ class Config {
383
386
  result.adHoc
384
387
  );
385
388
  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;
402
+ }
386
403
  }
387
404
  }
388
405
  return result;
@@ -405,11 +422,24 @@ class Config {
405
422
  getCdsOutboxEventSpecificConfig(serviceName, action) {
406
423
  const srv = cds.env.requires[serviceName];
407
424
  const config = srv?.outbox ?? srv?.queued;
408
- if (config?.events?.[action]) {
425
+ const specificConfig = config?.events?.[action];
426
+
427
+ if (specificConfig) {
409
428
  return this.#mixCAPPropertyNamesWithEventQueueNames(config.events[action]);
410
- } else {
429
+ }
430
+
431
+ if (!action) {
411
432
  return null;
412
433
  }
434
+
435
+ const [withoutSaga, sagaSuffix] = action.split("/");
436
+ if ([SAGA_FAILED, SAGA_SUCCESS].includes(sagaSuffix)) {
437
+ if (config?.events?.[withoutSaga]) {
438
+ return this.#mixCAPPropertyNamesWithEventQueueNames(config.events[withoutSaga]);
439
+ }
440
+ }
441
+
442
+ return null;
413
443
  }
414
444
 
415
445
  #mapEnvEvents(events) {
@@ -488,6 +518,7 @@ class Config {
488
518
  ? Object.fromEntries(new Map(event.appInstances.map((a) => [a, true])))
489
519
  : null;
490
520
  event.propagateHeaders = event.propagateHeaders ?? [];
521
+ event.propagatedContextProperties = event.propagatedContextProperties ?? [];
491
522
  event.retryFailedAfter = event.retryFailedAfter ?? DEFAULT_RETRY_AFTER;
492
523
  event.retryOpenAfter = event.retryOpenAfter ?? DEFAULT_RETRY_AFTER;
493
524
  }
package/src/initialize.js CHANGED
@@ -123,6 +123,7 @@ const initialize = async (options = {}) => {
123
123
  multiTenancyEnabled: config.isMultiTenancy,
124
124
  redisEnabled: config.redisEnabled,
125
125
  runInterval: config.runInterval,
126
+ useAsCAPQueue: config.useAsCAPQueue,
126
127
  });
127
128
  resolveFn();
128
129
  };
@@ -14,6 +14,8 @@ const EVENT_QUEUE_ACTIONS = {
14
14
  EXCEEDED: "eventQueueRetriesExceeded",
15
15
  CLUSTER: "eventQueueCluster",
16
16
  CHECK_AND_ADJUST: "eventQueueCheckAndAdjustPayload",
17
+ SAGA_SUCCESS: "#succeeded",
18
+ SAGA_FAILED: "#failed",
17
19
  };
18
20
 
19
21
  class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
@@ -25,7 +27,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
25
27
  async getQueueEntriesAndSetToInProgress() {
26
28
  const { srvName } = config.normalizeSubType(this.eventType, this.eventSubType);
27
29
  this.__srv = await cds.connect.to(srvName);
28
- this.__srvUnboxed = cds.unboxed(this.__srv);
30
+ this.__srvUnboxed = cds.unqueued(this.__srv);
29
31
  const { handlers, clusterRelevant, specificClusterRelevant } = this.__srvUnboxed.handlers.on.reduce(
30
32
  (result, handler) => {
31
33
  if (handler.on.startsWith(EVENT_QUEUE_ACTIONS.CLUSTER)) {
@@ -260,7 +262,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
260
262
  async checkEventAndGeneratePayload(queueEntry) {
261
263
  const payload = await super.checkEventAndGeneratePayload(queueEntry);
262
264
  const { event } = payload;
263
- const handlerName = this.#checkHandlerExists(EVENT_QUEUE_ACTIONS.CHECK_AND_ADJUST, event);
265
+ const handlerName = this.#checkHandlerExists({ eventQueueFn: EVENT_QUEUE_ACTIONS.CHECK_AND_ADJUST, event });
264
266
  if (!handlerName) {
265
267
  return payload;
266
268
  }
@@ -281,7 +283,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
281
283
 
282
284
  async hookForExceededEvents(exceededEvent) {
283
285
  const { event } = exceededEvent.payload;
284
- const handlerName = this.#checkHandlerExists(EVENT_QUEUE_ACTIONS.EXCEEDED, event);
286
+ const handlerName = this.#checkHandlerExists({ eventQueueFn: EVENT_QUEUE_ACTIONS.EXCEEDED, event });
285
287
  if (!handlerName) {
286
288
  return await super.hookForExceededEvents(exceededEvent);
287
289
  }
@@ -299,14 +301,23 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
299
301
  return await super.beforeProcessingEvents();
300
302
  }
301
303
 
302
- #checkHandlerExists(eventQueueFn, event) {
303
- const specificHandler = this.__onHandlers[[eventQueueFn, event].join(".")];
304
+ #checkHandlerExists({ eventQueueFn, event, saga } = {}) {
305
+ if (eventQueueFn) {
306
+ const specificHandler = this.__onHandlers[[eventQueueFn, event].join(".")];
307
+ if (specificHandler) {
308
+ return specificHandler;
309
+ }
310
+
311
+ const genericHandler = this.__onHandlers[eventQueueFn];
312
+ return genericHandler ?? null;
313
+ }
314
+
315
+ const specificHandler = this.__onHandlers[[event, saga].join("/")];
304
316
  if (specificHandler) {
305
317
  return specificHandler;
306
318
  }
307
319
 
308
- const genericHandler = this.__onHandlers[eventQueueFn];
309
- return genericHandler ?? null;
320
+ return this.__onHandlers[saga];
310
321
  }
311
322
 
312
323
  async processPeriodicEvent(processContext, key, queueEntry) {
@@ -344,7 +355,9 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
344
355
  const { userId, invocationFn, reg } = this.#buildDispatchData(processContext, payload, { key, queueEntries });
345
356
  await this.#setContextUser(processContext, userId, reg);
346
357
  const result = await this.__srvUnboxed.tx(processContext)[invocationFn](reg);
347
- return this.#determineResultStatus(result, queueEntries);
358
+ const statusTuple = this.#determineResultStatus(result, queueEntries);
359
+ await this.#publishFollowupEvents(processContext, reg, statusTuple);
360
+ return statusTuple;
348
361
  } catch (err) {
349
362
  this.logger.error("error processing outboxed service call", err, {
350
363
  serviceName: this.eventSubType,
@@ -359,11 +372,51 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
359
372
  }
360
373
  }
361
374
 
375
+ async #publishFollowupEvents(processContext, req, statusTuple) {
376
+ const succeeded = this.#checkHandlerExists({ event: req.event, saga: EVENT_QUEUE_ACTIONS.SAGA_SUCCESS });
377
+ const failed = this.#checkHandlerExists({ event: req.event, saga: EVENT_QUEUE_ACTIONS.SAGA_FAILED });
378
+
379
+ if (!succeeded && !failed) {
380
+ return;
381
+ }
382
+
383
+ if (req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS) || req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_FAILED)) {
384
+ return;
385
+ }
386
+
387
+ // NOTE: required for #failed because tx is rolledback and new events would not be commmited!
388
+ const tx = cds.tx(processContext);
389
+ const nextEvents = tx._eventQueue?.events;
390
+
391
+ if (nextEvents?.length) {
392
+ tx._eventQueue.events = [];
393
+ }
394
+
395
+ for (const [, result] of statusTuple) {
396
+ if (succeeded && result.status === EventProcessingStatus.Done) {
397
+ await this.__srv.tx(processContext).send(succeeded, result.nextData ?? req.data);
398
+ }
399
+
400
+ if (failed && result.status === EventProcessingStatus.Error) {
401
+ await this.__srv.tx(processContext).send(failed, result.nextData ?? req.data);
402
+ }
403
+
404
+ delete result.nextData;
405
+ }
406
+
407
+ if (config.insertEventsBeforeCommit) {
408
+ this.nextSagaEvents = tx._eventQueue.events;
409
+ } else {
410
+ this.nextSagaEvents = tx._eventQueue.events.filter((event) => JSON.parse(event.payload).event === failed);
411
+ }
412
+ tx._eventQueue.events = nextEvents ?? [];
413
+ }
414
+
362
415
  #determineResultStatus(result, queueEntries) {
363
416
  const validStatusValues = Object.values(EventProcessingStatus);
364
417
  const validStatus = validStatusValues.includes(result);
365
418
  if (validStatus) {
366
- return queueEntries.map((queueEntry) => [queueEntry.ID, result]);
419
+ return queueEntries.map((queueEntry) => [queueEntry.ID, { status: result }]);
367
420
  }
368
421
 
369
422
  if (result instanceof Object && !Array.isArray(result)) {
@@ -375,7 +428,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
375
428
  }
376
429
 
377
430
  if (!Array.isArray(result)) {
378
- return queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Done]);
431
+ return queueEntries.map((queueEntry) => [queueEntry.ID, { status: EventProcessingStatus.Done }]);
379
432
  }
380
433
 
381
434
  const [firstEntry] = result;
@@ -430,7 +483,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
430
483
  if (valid) {
431
484
  return result;
432
485
  } else {
433
- return queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Done]);
486
+ return queueEntries.map((queueEntry) => [queueEntry.ID, { status: EventProcessingStatus.Done }]);
434
487
  }
435
488
  }
436
489
  }
@@ -43,8 +43,9 @@ function outboxed(srv, customOpts) {
43
43
 
44
44
  const outboxOpts = config.getEventConfig(CDS_EVENT_TYPE, subType, srvConfig.namespace);
45
45
  const eventHeaders = getPropagatedHeaders(outboxOpts, req);
46
+ const contextProperties = getPropagatedContextProperties(outboxOpts, req);
46
47
  if (["persistent-outbox", "persistent-queue"].includes(outboxOpts.kind)) {
47
- await _mapToEventAndPublish(context, subType, req, eventHeaders, srvConfig.namespace);
48
+ await _mapToEventAndPublish(req, srvConfig.namespace, subType, eventHeaders, contextProperties);
48
49
  return;
49
50
  }
50
51
  context.on("succeeded", async () => {
@@ -80,7 +81,17 @@ const getPropagatedHeaders = (config, req) => {
80
81
  return Object.assign(propagateHeaders, req.headers);
81
82
  };
82
83
 
83
- const _mapToEventAndPublish = async (context, subType, req, eventHeaders, namespace) => {
84
+ const getPropagatedContextProperties = (config, req) => {
85
+ return config.propagatedContextProperties.reduce((properties, name) => {
86
+ if (name in req.tx.context) {
87
+ properties[name] = req.tx.context[name];
88
+ }
89
+ return properties;
90
+ }, {});
91
+ };
92
+
93
+ const _mapToEventAndPublish = async (req, namespace, subType, eventHeaders, contextProperties) => {
94
+ const context = req.context || cds.context;
84
95
  const eventQueueSpecificValues = {};
85
96
  for (const header in req.headers ?? {}) {
86
97
  for (const field of EVENT_QUEUE_SPECIFIC_FIELDS) {
@@ -100,6 +111,7 @@ const _mapToEventAndPublish = async (context, subType, req, eventHeaders, namesp
100
111
  ...(req.data && { data: req.data }),
101
112
  ...(eventHeaders && { headers: eventHeaders }),
102
113
  ...(req.query && { query: req.query }),
114
+ ...(Object.keys(contextProperties).length && { ...contextProperties }),
103
115
  };
104
116
 
105
117
  await publishEvent(
@@ -76,8 +76,9 @@ const publishEvent = async (
76
76
  event.namespace = config.namespace;
77
77
  }
78
78
  }
79
+ _addEventsToContext(tx, events);
79
80
  if (config.insertEventsBeforeCommit && !skipInsertEventsBeforeCommit) {
80
- _registerHandlerAndAddEvents(tx, events, skipBroadcast);
81
+ _registerHandler(tx, skipBroadcast);
81
82
  } else {
82
83
  let result;
83
84
  tx._skipEventQueueBroadcast = skipBroadcast;
@@ -87,10 +88,7 @@ const publishEvent = async (
87
88
  }
88
89
  };
89
90
 
90
- const _registerHandlerAndAddEvents = (tx, events, skipBroadcast) => {
91
- tx._eventQueue ??= { events: [], handlerRegistered: false };
92
- tx._eventQueue.events = tx._eventQueue.events.concat(events);
93
-
91
+ const _registerHandler = (tx, skipBroadcast) => {
94
92
  if (tx._eventQueue.handlerRegistered) {
95
93
  return;
96
94
  }
@@ -106,6 +104,11 @@ const _registerHandlerAndAddEvents = (tx, events, skipBroadcast) => {
106
104
  });
107
105
  };
108
106
 
107
+ const _addEventsToContext = (tx, events) => {
108
+ tx._eventQueue ??= { events: [], handlerRegistered: false };
109
+ tx._eventQueue.events = tx._eventQueue.events.concat(events);
110
+ };
111
+
109
112
  module.exports = {
110
113
  publishEvent,
111
114
  };
@@ -1,7 +1,5 @@
1
1
  "use strict";
2
2
 
3
- const { promisify } = require("util");
4
-
5
3
  const cds = require("@sap/cds");
6
4
 
7
5
  const redis = require("../shared/redis");
@@ -17,7 +15,11 @@ const COMPONENT_NAME = "/eventQueue/redisPub";
17
15
  const TRIES_FOR_PUBLISH_PERIODIC_EVENT = 10;
18
16
  const SLEEP_TIME_FOR_PUBLISH_PERIODIC_EVENT = 30 * 1000;
19
17
 
20
- const wait = promisify(setTimeout);
18
+ const wait = async (ms) => {
19
+ return new Promise((resolve) => {
20
+ setTimeout(resolve, ms).unref();
21
+ });
22
+ };
21
23
 
22
24
  /**
23
25
  * Broadcasts events to the event queue, either locally or through Redis.
@@ -130,7 +130,7 @@ const _getAllTenantBase = async () => {
130
130
  if (cds.services["cds.xt.SaasProvisioningService"] || cds.services["saas-registry"]) {
131
131
  break;
132
132
  }
133
- await new Promise((resolve) => setTimeout(resolve, 1000));
133
+ await new Promise((resolve) => setTimeout(resolve, 1000).unref());
134
134
  }
135
135
 
136
136
  const ssp = await cds.connect.to("cds.xt.SaasProvisioningService");