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

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.1",
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",
@@ -22,8 +22,6 @@
22
22
  "multi-tenancy"
23
23
  ],
24
24
  "scripts": {
25
- "start": "PORT=4005 cds-serve",
26
- "watch": "PORT=4005 cds watch",
27
25
  "test:unit": "jest --selectProjects unit",
28
26
  "test:integration": "jest --selectProjects integration --runInBand",
29
27
  "voter:test:integration": "jest --selectProjects integration",
@@ -38,13 +36,12 @@
38
36
  "eslint:ci": "eslint .",
39
37
  "prettier": "prettier --write --loglevel error .",
40
38
  "prettier:ci": "prettier --check .",
41
- "prepareRelease": "npm prune --production",
42
39
  "docs": "cd docs && bundle exec jekyll serve",
43
40
  "docs:install": "cd docs && npx shx rm -rf vendor Gemfile.lock && bundle install",
44
41
  "upgrade-lock": "npx shx rm -rf package-lock.json node_modules && npm i --package-lock"
45
42
  },
46
43
  "engines": {
47
- "node": ">=18"
44
+ "node": ">=20"
48
45
  },
49
46
  "dependencies": {
50
47
  "@cap-js-community/common": "^0.3.4",
@@ -56,17 +53,16 @@
56
53
  "devDependencies": {
57
54
  "@actions/core": "^2.0.2",
58
55
  "@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",
56
+ "@cap-js/db-service": "^2.8.2",
57
+ "@cap-js/hana": "^2.6.0",
58
+ "@cap-js/sqlite": "^2.1.3",
62
59
  "@opentelemetry/api": "^1.9.0",
63
- "@sap/cds": "^9.6.4",
64
- "@sap/cds-dk": "^9.6.1",
60
+ "@sap/cds": "^9.7.0",
61
+ "@sap/cds-dk": "^9.7.0",
65
62
  "eslint": "^8.57.1",
66
63
  "eslint-config-prettier": "^10.1.8",
67
64
  "eslint-plugin-jest": "^29.12.1",
68
65
  "eslint-plugin-node": "^11.1.0",
69
- "express": "^4.22.1",
70
66
  "jest": "^29.7.0",
71
67
  "prettier": "^2.8.8"
72
68
  },
@@ -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;
@@ -407,7 +408,7 @@ class EventQueueProcessorBase {
407
408
  UPDATE.entity(this.#config.tableNameEventQueue)
408
409
  .set({
409
410
  status: status,
410
- ...(error && { error: this.#error2String(error) }),
411
+ ...(error && { error: this._error2String(error) }),
411
412
  })
412
413
  .where({
413
414
  ID: queueEntryIds,
@@ -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?
@@ -493,7 +498,7 @@ class EventQueueProcessorBase {
493
498
  }
494
499
 
495
500
  if (data.error) {
496
- data.error = this.#error2String(data.error);
501
+ data.error = this._error2String(data.error);
497
502
  }
498
503
 
499
504
  if (!data.startAfter && [EventProcessingStatus.Error, EventProcessingStatus.Open].includes(data.status)) {
@@ -522,10 +527,15 @@ 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
 
528
- #error2String(error) {
538
+ _error2String(error) {
529
539
  return JSON.stringify(error, (_, value) => this.#errorReplacer(value));
530
540
  }
531
541
 
@@ -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,27 @@ 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
+ if (event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS)) {
316
+ [event] = event.split("/");
317
+ }
318
+
319
+ const specificHandler = this.__onHandlers[[event, saga].join("/")];
304
320
  if (specificHandler) {
305
321
  return specificHandler;
306
322
  }
307
323
 
308
- const genericHandler = this.__onHandlers[eventQueueFn];
309
- return genericHandler ?? null;
324
+ return this.__onHandlers[saga];
310
325
  }
311
326
 
312
327
  async processPeriodicEvent(processContext, key, queueEntry) {
@@ -340,16 +355,17 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
340
355
  }
341
356
 
342
357
  async processEvent(processContext, key, queueEntries, payload) {
358
+ let statusTuple;
359
+ const { userId, invocationFn, reg } = this.#buildDispatchData(processContext, payload, { key, queueEntries });
343
360
  try {
344
- const { userId, invocationFn, reg } = this.#buildDispatchData(processContext, payload, { key, queueEntries });
345
361
  await this.#setContextUser(processContext, userId, reg);
346
362
  const result = await this.__srvUnboxed.tx(processContext)[invocationFn](reg);
347
- return this.#determineResultStatus(result, queueEntries);
363
+ statusTuple = this.#determineResultStatus(result, queueEntries);
348
364
  } catch (err) {
349
365
  this.logger.error("error processing outboxed service call", err, {
350
366
  serviceName: this.eventSubType,
351
367
  });
352
- return queueEntries.map((queueEntry) => [
368
+ statusTuple = queueEntries.map((queueEntry) => [
353
369
  queueEntry.ID,
354
370
  {
355
371
  status: EventProcessingStatus.Error,
@@ -357,13 +373,65 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
357
373
  },
358
374
  ]);
359
375
  }
376
+
377
+ await this.#publishFollowupEvents(processContext, reg, statusTuple);
378
+ return statusTuple;
379
+ }
380
+
381
+ async #publishFollowupEvents(processContext, req, statusTuple) {
382
+ const succeeded = this.#checkHandlerExists({ event: req.event, saga: EVENT_QUEUE_ACTIONS.SAGA_SUCCESS });
383
+ const failed = this.#checkHandlerExists({ event: req.event, saga: EVENT_QUEUE_ACTIONS.SAGA_FAILED });
384
+
385
+ if (!succeeded && !failed) {
386
+ return;
387
+ }
388
+
389
+ if (req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_FAILED)) {
390
+ return;
391
+ }
392
+
393
+ // NOTE: required for #failed because tx is rolledback and new events would not be commmited!
394
+ const tx = cds.tx(processContext);
395
+ const nextEvents = tx._eventQueue?.events;
396
+
397
+ if (nextEvents?.length) {
398
+ tx._eventQueue.events = [];
399
+ }
400
+
401
+ for (const [, result] of statusTuple) {
402
+ const data = result.nextData ?? req.data;
403
+ if (
404
+ succeeded &&
405
+ result.status === EventProcessingStatus.Done &&
406
+ !req.event.endsWith(EVENT_QUEUE_ACTIONS.SAGA_SUCCESS)
407
+ ) {
408
+ await this.__srv.tx(processContext).send(succeeded, data);
409
+ }
410
+
411
+ if (failed && result.status === EventProcessingStatus.Error) {
412
+ result.error && (data.error = this._error2String(result.error));
413
+ await this.__srv.tx(processContext).send(failed, data);
414
+ }
415
+
416
+ delete result.nextData;
417
+ }
418
+
419
+ if (config.insertEventsBeforeCommit) {
420
+ this.nextSagaEvents = tx._eventQueue?.events;
421
+ } else {
422
+ this.nextSagaEvents = tx._eventQueue?.events.filter((event) => JSON.parse(event.payload).event === failed);
423
+ }
424
+
425
+ if (tx._eventQueue) {
426
+ tx._eventQueue.events = nextEvents ?? [];
427
+ }
360
428
  }
361
429
 
362
430
  #determineResultStatus(result, queueEntries) {
363
431
  const validStatusValues = Object.values(EventProcessingStatus);
364
432
  const validStatus = validStatusValues.includes(result);
365
433
  if (validStatus) {
366
- return queueEntries.map((queueEntry) => [queueEntry.ID, result]);
434
+ return queueEntries.map((queueEntry) => [queueEntry.ID, { status: result }]);
367
435
  }
368
436
 
369
437
  if (result instanceof Object && !Array.isArray(result)) {
@@ -375,7 +443,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
375
443
  }
376
444
 
377
445
  if (!Array.isArray(result)) {
378
- return queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Done]);
446
+ return queueEntries.map((queueEntry) => [queueEntry.ID, { status: EventProcessingStatus.Done }]);
379
447
  }
380
448
 
381
449
  const [firstEntry] = result;
@@ -430,7 +498,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
430
498
  if (valid) {
431
499
  return result;
432
500
  } else {
433
- return queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Done]);
501
+ return queueEntries.map((queueEntry) => [queueEntry.ID, { status: EventProcessingStatus.Done }]);
434
502
  }
435
503
  }
436
504
  }
@@ -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");