@cap-js-community/event-queue 1.0.3 → 1.1.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": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "An event queue that enables secure transactional processing of asynchronous events, featuring instant event processing with Redis Pub/Sub and load distribution across all application instances.",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -47,17 +47,17 @@
47
47
  "yaml": "2.3.4"
48
48
  },
49
49
  "devDependencies": {
50
- "@sap/cds": "7.5.1",
51
- "@sap/cds-dk": "7.5.0",
50
+ "@sap/cds": "7.5.2",
51
+ "@sap/cds-dk": "7.5.1",
52
52
  "eslint": "8.56.0",
53
53
  "eslint-config-prettier": "9.1.0",
54
- "eslint-plugin-jest": "27.6.1",
54
+ "eslint-plugin-jest": "27.6.2",
55
55
  "eslint-plugin-node": "11.1.0",
56
56
  "express": "4.18.2",
57
57
  "hdb": "0.19.7",
58
58
  "jest": "29.7.0",
59
59
  "prettier": "2.8.8",
60
- "sqlite3": "5.1.7-rc.0"
60
+ "sqlite3": "5.1.7"
61
61
  },
62
62
  "homepage": "https://cap-js-community.github.io/event-queue/",
63
63
  "repository": {
@@ -12,7 +12,7 @@ const eventConfig = require("./config");
12
12
  const PerformanceTracer = require("./shared/PerformanceTracer");
13
13
 
14
14
  const IMPLEMENT_ERROR_MESSAGE = "needs to be reimplemented";
15
- const COMPONENT_NAME = "eventQueue/EventQueueProcessorBase";
15
+ const COMPONENT_NAME = "/eventQueue/EventQueueProcessorBase";
16
16
 
17
17
  const DEFAULT_RETRY_ATTEMPTS = 3;
18
18
  const DEFAULT_PARALLEL_EVENT_PROCESSING = 1;
@@ -869,7 +869,7 @@ class EventQueueProcessorBase {
869
869
  return await checkAndUpdatePromise;
870
870
  }
871
871
 
872
- async handleDistributedLock() {
872
+ async acquireDistributedLock() {
873
873
  if (this.concurrentEventProcessing) {
874
874
  return true;
875
875
  }
package/src/config.js CHANGED
@@ -10,12 +10,15 @@ const FOR_UPDATE_TIMEOUT = 10;
10
10
  const GLOBAL_TX_TIMEOUT = 30 * 60 * 1000;
11
11
  const REDIS_CONFIG_CHANNEL = "EVENT_QUEUE_CONFIG_CHANNEL";
12
12
  const REDIS_CONFIG_BLOCKLIST_CHANNEL = "REDIS_CONFIG_BLOCKLIST_CHANNEL";
13
- const COMPONENT_NAME = "eventQueue/config";
13
+ const COMPONENT_NAME = "/eventQueue/config";
14
14
  const MIN_INTERVAL_SEC = 10;
15
15
  const DEFAULT_LOAD = 1;
16
16
  const SUFFIX_PERIODIC = "_PERIODIC";
17
17
  const COMMAND_BLOCK = "EVENT_QUEUE_EVENT_BLOCK";
18
18
  const COMMAND_UNBLOCK = "EVENT_QUEUE_EVENT_UNBLOCK";
19
+ const CAP_EVENT_TYPE = "CAP_OUTBOX";
20
+
21
+ const CAP_PARALLEL_DEFAULT = 5;
19
22
 
20
23
  const BASE_PERIODIC_EVENTS = [
21
24
  {
@@ -51,6 +54,7 @@ class Config {
51
54
  #blockedPeriodicEvents;
52
55
  #isPeriodicEventBlockedCb;
53
56
  #thresholdLoggingEventProcessing;
57
+ #useAsCAPOutbox;
54
58
  static #instance;
55
59
  constructor() {
56
60
  this.#logger = cds.log(COMPONENT_NAME);
@@ -76,6 +80,10 @@ class Config {
76
80
  return this.#eventMap[this.generateKey(type, subType)];
77
81
  }
78
82
 
83
+ isCapOutboxEvent(type) {
84
+ return type === CAP_EVENT_TYPE;
85
+ }
86
+
79
87
  hasEventAfterCommitFlag(type, subType) {
80
88
  return this.#eventMap[this.generateKey(type, subType)]?.processAfterCommit ?? true;
81
89
  }
@@ -180,6 +188,30 @@ class Config {
180
188
  });
181
189
  }
182
190
 
191
+ addCAPOutboxEvent(serviceName, config) {
192
+ if (this.#eventMap[this.generateKey(CAP_EVENT_TYPE, serviceName)]) {
193
+ return;
194
+ }
195
+
196
+ const eventConfig = {
197
+ type: CAP_EVENT_TYPE,
198
+ subType: serviceName,
199
+ load: config.load ?? DEFAULT_LOAD,
200
+ impl: "./outbox/EventQueueGenericOutboxHandler",
201
+ selectMaxChunkSize: config.chunkSize,
202
+ parallelEventProcessing: config.parallelEventProcessing ?? (config.parallel && CAP_PARALLEL_DEFAULT),
203
+ retryAttempts: config.maxAttempts,
204
+ transactionMode: config.transactionMode,
205
+ processAfterCommit: config.processAfterCommit,
206
+ eventOutdatedCheck: config.eventOutdatedCheck,
207
+ checkForNextChunk: config.checkForNextChunk,
208
+ deleteFinishedEventsAfterDays: config.deleteFinishedEventsAfterDays,
209
+ internalEvent: true,
210
+ };
211
+ this.#config.events.push(eventConfig);
212
+ this.#eventMap[this.generateKey(CAP_EVENT_TYPE, serviceName)] = eventConfig;
213
+ }
214
+
183
215
  #unblockPeriodicEventLocalState(key, tenant) {
184
216
  const map = this.#blockedPeriodicEvents[key];
185
217
  if (!map) {
@@ -214,7 +246,7 @@ class Config {
214
246
  this.#eventMap = config.events.reduce((result, event) => {
215
247
  event.load = event.load ?? DEFAULT_LOAD;
216
248
  this.validateAdHocEvents(result, event);
217
- result[[event.type, event.subType].join("##")] = event;
249
+ result[this.generateKey(event.type, event.subType)] = event;
218
250
  return result;
219
251
  }, {});
220
252
  this.#eventMap = config.periodicEvents.reduce((result, event) => {
@@ -222,7 +254,7 @@ class Config {
222
254
  event.type = `${event.type}${SUFFIX_PERIODIC}`;
223
255
  event.isPeriodic = true;
224
256
  this.validatePeriodicConfig(result, event);
225
- result[[event.type, event.subType].join("##")] = event;
257
+ result[this.generateKey(event.type, event.subType)] = event;
226
258
  return result;
227
259
  }, this.#eventMap);
228
260
  }
@@ -257,6 +289,14 @@ class Config {
257
289
  return [type, subType].join("##");
258
290
  }
259
291
 
292
+ removeEvent(type, subType) {
293
+ const index = this.#config.events.findIndex((event) => event.type === "CAP_OUTBOX");
294
+ if (index >= 0) {
295
+ this.#config.events.splice(index, 1);
296
+ }
297
+ delete this.#eventMap[this.generateKey(type, subType)];
298
+ }
299
+
260
300
  get fileContent() {
261
301
  return this.#config;
262
302
  }
@@ -405,6 +445,14 @@ class Config {
405
445
  return this.#thresholdLoggingEventProcessing;
406
446
  }
407
447
 
448
+ set useAsCAPOutbox(value) {
449
+ this.#useAsCAPOutbox = value;
450
+ }
451
+
452
+ get useAsCAPOutbox() {
453
+ return this.#useAsCAPOutbox;
454
+ }
455
+
408
456
  get isMultiTenancy() {
409
457
  return !!cds.requires.multitenancy;
410
458
  }
package/src/dbHandler.js CHANGED
@@ -5,7 +5,7 @@ const cds = require("@sap/cds");
5
5
  const { broadcastEvent } = require("./redisPubSub");
6
6
  const config = require("./config");
7
7
 
8
- const COMPONENT_NAME = "eventQueue/dbHandler";
8
+ const COMPONENT_NAME = "/eventQueue/dbHandler";
9
9
 
10
10
  const registerEventQueueDbHandler = (dbService) => {
11
11
  const def = dbService.model.definitions[config.tableNameEventQueue];
package/src/initialize.js CHANGED
@@ -14,6 +14,7 @@ const dbHandler = require("./dbHandler");
14
14
  const config = require("./config");
15
15
  const { initEventQueueRedisSubscribe, closeSubscribeClient } = require("./redisPubSub");
16
16
  const { closeMainClient } = require("./shared/redis");
17
+ const eventQueueAsOutbox = require("./outbox/eventQueueAsOutbox");
17
18
 
18
19
  const readFileAsync = promisify(fs.readFile);
19
20
 
@@ -35,6 +36,7 @@ const CONFIG_VARS = [
35
36
  ["skipCsnCheck", false],
36
37
  ["updatePeriodicEvents", true],
37
38
  ["thresholdLoggingEventProcessing", 50],
39
+ ["useAsCAPOutbox", false],
38
40
  ];
39
41
 
40
42
  const initialize = async ({
@@ -49,6 +51,7 @@ const initialize = async ({
49
51
  skipCsnCheck,
50
52
  updatePeriodicEvents,
51
53
  thresholdLoggingEventProcessing,
54
+ useAsCAPOutbox,
52
55
  } = {}) => {
53
56
  // TODO: initialize check:
54
57
  // - content of yaml check
@@ -70,7 +73,8 @@ const initialize = async ({
70
73
  disableRedis,
71
74
  skipCsnCheck,
72
75
  updatePeriodicEvents,
73
- thresholdLoggingEventProcessing
76
+ thresholdLoggingEventProcessing,
77
+ useAsCAPOutbox
74
78
  );
75
79
 
76
80
  const logger = cds.log(COMPONENT);
@@ -84,6 +88,7 @@ const initialize = async ({
84
88
  dbHandler.registerEventQueueDbHandler(dbService);
85
89
  }
86
90
 
91
+ monkeyPatchCAPOutbox();
87
92
  registerEventProcessors();
88
93
  registerCdsShutdown();
89
94
  logger.info("event queue initialized", {
@@ -133,6 +138,17 @@ const registerEventProcessors = () => {
133
138
  }
134
139
  };
135
140
 
141
+ const monkeyPatchCAPOutbox = () => {
142
+ if (config.useAsCAPOutbox) {
143
+ Object.defineProperty(cds, "outboxed", {
144
+ get: () => eventQueueAsOutbox.outboxed,
145
+ });
146
+ Object.defineProperty(cds, "unboxed", {
147
+ get: () => eventQueueAsOutbox.unboxed,
148
+ });
149
+ }
150
+ };
151
+
136
152
  const csnCheck = async () => {
137
153
  const eventCsn = cds.model.definitions[config.tableNameEventQueue];
138
154
  if (!eventCsn) {
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+
3
+ const cds = require("@sap/cds");
4
+
5
+ const EventQueueBaseClass = require("../EventQueueProcessorBase");
6
+ const { EventProcessingStatus } = require("../constants");
7
+
8
+ const COMPONENT_NAME = "/eventQueue/outbox/generic";
9
+
10
+ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
11
+ constructor(context, eventType, eventSubType, config) {
12
+ super(context, eventType, eventSubType, config);
13
+ this.logger = cds.log(`${COMPONENT_NAME}/${eventSubType}`);
14
+ }
15
+
16
+ async processEvent(processContext, key, queueEntries, payload) {
17
+ let status = EventProcessingStatus.Done;
18
+ try {
19
+ const service = await cds.connect.to(this.eventSubType);
20
+ const userId = payload.contextUser;
21
+ const msg = payload._fromSend ? new cds.Request(payload) : new cds.Event(payload);
22
+ const invocationFn = payload._fromSend ? "send" : "emit";
23
+ delete msg._fromSend;
24
+ delete msg.contextUser;
25
+ processContext.user = new cds.User.Privileged(userId);
26
+ await cds.unboxed(service).tx(processContext)[invocationFn](msg);
27
+ } catch (err) {
28
+ status = EventProcessingStatus.Error;
29
+ this.logger.error("error processing outboxed service call", err, {
30
+ serviceName: this.eventSubType,
31
+ });
32
+ }
33
+ return queueEntries.map((queueEntry) => [queueEntry.ID, status]);
34
+ }
35
+ }
36
+
37
+ module.exports = EventQueueGenericOutboxHandler;
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ const cds = require("@sap/cds");
4
+
5
+ const { publishEvent } = require("../publishEvent");
6
+ const config = require("../config");
7
+
8
+ const OUTBOXED = Symbol("outboxed");
9
+ const UNBOXED = Symbol("unboxed");
10
+
11
+ const CDS_EVENT_TYPE = "CAP_OUTBOX";
12
+
13
+ const COMPONENT_NAME = "/eventQueue/eventQueueAsOutbox";
14
+
15
+ function outboxed(srv, customOpts) {
16
+ // outbox max. once
17
+ const logger = cds.log(COMPONENT_NAME);
18
+ if (!new.target) {
19
+ const former = srv[OUTBOXED];
20
+ if (former) {
21
+ return former;
22
+ }
23
+ }
24
+
25
+ const originalSrv = srv[UNBOXED] || srv;
26
+ const outboxedSrv = Object.create(originalSrv);
27
+ outboxedSrv[UNBOXED] = originalSrv;
28
+
29
+ if (!new.target) {
30
+ Object.defineProperty(srv, OUTBOXED, { value: outboxedSrv });
31
+ }
32
+
33
+ const outboxOpts = Object.assign(
34
+ {},
35
+ (typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
36
+ (typeof srv.options?.outbox === "object" && srv.options.outbox) || {},
37
+ customOpts || {}
38
+ );
39
+
40
+ config.addCAPOutboxEvent(srv.name, outboxOpts);
41
+ outboxedSrv.handle = async function (req) {
42
+ const context = req.context || cds.context;
43
+ if (outboxOpts.kind === "persistent-outbox") {
44
+ config.addCAPOutboxEvent(srv.name, outboxOpts);
45
+ await _mapToEventAndPublish(context, srv.name, req);
46
+ return;
47
+ }
48
+ context.on("succeeded", async () => {
49
+ try {
50
+ if (req.reply) {
51
+ await originalSrv.send(req);
52
+ } else {
53
+ await originalSrv.emit(req);
54
+ }
55
+ } catch (err) {
56
+ logger.error("In memory processing failed", { event: req.event, cause: err });
57
+ if (isUnrecoverable(originalSrv, err) && outboxOpts.crashOnError !== false) {
58
+ cds.exit(1);
59
+ }
60
+ }
61
+ });
62
+ };
63
+
64
+ return outboxedSrv;
65
+ }
66
+
67
+ function unboxed(srv) {
68
+ return srv[UNBOXED] || srv;
69
+ }
70
+
71
+ const _mapToEventAndPublish = async (context, name, msg) => {
72
+ const event = {
73
+ contextUser: context.user.id,
74
+ ...(msg._fromSend || (msg.reply && { _fromSend: true })), // send or emit
75
+ ...(msg.inbound && { inbound: msg.inbound }),
76
+ ...(msg.event && { event: msg.event }),
77
+ ...(msg.data && { data: msg.data }),
78
+ ...(msg.headers && { headers: msg.headers }),
79
+ ...(msg.query && { query: msg.query }),
80
+ };
81
+
82
+ await publishEvent(cds.tx(context), {
83
+ type: CDS_EVENT_TYPE,
84
+ subType: name,
85
+ payload: JSON.stringify(event),
86
+ });
87
+ };
88
+
89
+ const isUnrecoverable = (service, error) => {
90
+ let unrecoverable = service.isUnrecoverableError && service.isUnrecoverableError(error);
91
+ if (unrecoverable === undefined) {
92
+ unrecoverable = error.unrecoverable;
93
+ }
94
+ return unrecoverable || isStandardError(error);
95
+ };
96
+
97
+ const isStandardError = (err) => {
98
+ return (
99
+ err instanceof TypeError ||
100
+ err instanceof ReferenceError ||
101
+ err instanceof SyntaxError ||
102
+ err instanceof RangeError ||
103
+ err instanceof URIError
104
+ );
105
+ };
106
+
107
+ module.exports = {
108
+ outboxed,
109
+ unboxed,
110
+ };
@@ -6,7 +6,7 @@ const { EventProcessingStatus } = require("./constants");
6
6
  const { processChunkedSync } = require("./shared/common");
7
7
  const eventConfig = require("./config");
8
8
 
9
- const COMPONENT_NAME = "eventQueue/periodicEvents";
9
+ const COMPONENT_NAME = "/eventQueue/periodicEvents";
10
10
  const CHUNK_SIZE_INSERT_PERIODIC_EVENTS = 4;
11
11
 
12
12
  const checkAndInsertPeriodicEvents = async (context) => {
@@ -10,7 +10,7 @@ const { limiter } = require("./shared/common");
10
10
 
11
11
  const { executeInNewTransaction, TriggerRollback } = require("./shared/cdsHelper");
12
12
 
13
- const COMPONENT_NAME = "eventQueue/processEventQueue";
13
+ const COMPONENT_NAME = "/eventQueue/processEventQueue";
14
14
  const MAX_EXECUTION_TIME = 5 * 60 * 1000;
15
15
 
16
16
  const processEventQueue = async (context, eventType, eventSubType, startTime = new Date()) => {
@@ -29,7 +29,7 @@ const processEventQueue = async (context, eventType, eventSubType, startTime = n
29
29
  return;
30
30
  }
31
31
  baseInstance = new EventTypeClass(context, eventType, eventSubType, eventConfig);
32
- const continueProcessing = await baseInstance.handleDistributedLock();
32
+ const continueProcessing = await baseInstance.acquireDistributedLock();
33
33
  if (!continueProcessing) {
34
34
  return;
35
35
  }
@@ -5,11 +5,11 @@ const cds = require("@sap/cds");
5
5
  const redis = require("./shared/redis");
6
6
  const { checkLockExistsAndReturnValue } = require("./shared/distributedLock");
7
7
  const config = require("./config");
8
- const { runEventCombinationForTenant } = require("./runner");
8
+ const runner = require("./runner");
9
9
  const { getSubdomainForTenantId } = require("./shared/cdsHelper");
10
10
 
11
11
  const EVENT_MESSAGE_CHANNEL = "EVENT_QUEUE_MESSAGE_CHANNEL";
12
- const COMPONENT_NAME = "eventQueue/redisPubSub";
12
+ const COMPONENT_NAME = "/eventQueue/redisPubSub";
13
13
 
14
14
  let subscriberClientPromise;
15
15
 
@@ -17,10 +17,10 @@ const initEventQueueRedisSubscribe = () => {
17
17
  if (subscriberClientPromise || !config.redisEnabled) {
18
18
  return;
19
19
  }
20
- redis.subscribeRedisChannel(EVENT_MESSAGE_CHANNEL, messageHandlerProcessEvents);
20
+ redis.subscribeRedisChannel(EVENT_MESSAGE_CHANNEL, _messageHandlerProcessEvents);
21
21
  };
22
22
 
23
- const messageHandlerProcessEvents = async (messageData) => {
23
+ const _messageHandlerProcessEvents = async (messageData) => {
24
24
  const logger = cds.log(COMPONENT_NAME);
25
25
  try {
26
26
  const { tenantId, type, subType } = JSON.parse(messageData);
@@ -43,8 +43,30 @@ const messageHandlerProcessEvents = async (messageData) => {
43
43
  // NOTE: we need this because of logging otherwise logs would not contain the subdomain
44
44
  http: { req: { authInfo: { getSubdomain: () => subdomain } } },
45
45
  };
46
+
47
+ if (!config.getEventConfig(type, subType)) {
48
+ if (config.isCapOutboxEvent(type)) {
49
+ try {
50
+ const service = await cds.connect.to(subType);
51
+ cds.outboxed(service);
52
+ } catch (err) {
53
+ logger.error("could not connect to outboxed service", err, {
54
+ type,
55
+ subType,
56
+ });
57
+ return;
58
+ }
59
+ } else {
60
+ logger.error("cannot find configuration for published event. Event won't be processed", {
61
+ type,
62
+ subType,
63
+ });
64
+ return;
65
+ }
66
+ }
67
+
46
68
  return await cds.tx(tenantContext, async ({ context }) => {
47
- return await runEventCombinationForTenant(context, type, subType);
69
+ return await runner.runEventCombinationForTenant(context, type, subType);
48
70
  });
49
71
  } catch (err) {
50
72
  logger.error("could not parse event information", {
@@ -76,7 +98,7 @@ const broadcastEvent = async (tenantId, type, subType) => {
76
98
  }
77
99
 
78
100
  return await cds.tx(context, async ({ context }) => {
79
- return await runEventCombinationForTenant(context, type, subType);
101
+ return await runner.runEventCombinationForTenant(context, type, subType);
80
102
  });
81
103
  }
82
104
  return;
@@ -122,4 +144,7 @@ module.exports = {
122
144
  initEventQueueRedisSubscribe,
123
145
  broadcastEvent,
124
146
  closeSubscribeClient,
147
+ __: {
148
+ _messageHandlerProcessEvents,
149
+ },
125
150
  };
package/src/runner.js CHANGED
@@ -12,7 +12,7 @@ const { getSubdomainForTenantId } = require("./shared/cdsHelper");
12
12
  const periodicEvents = require("./periodicEvents");
13
13
  const { hashStringTo32Bit } = require("./shared/common");
14
14
 
15
- const COMPONENT_NAME = "eventQueue/runner";
15
+ const COMPONENT_NAME = "/eventQueue/runner";
16
16
  const EVENT_QUEUE_RUN_ID = "EVENT_QUEUE_RUN_ID";
17
17
  const EVENT_QUEUE_RUN_TS = "EVENT_QUEUE_RUN_TS";
18
18
  const EVENT_QUEUE_RUN_PERIODIC_EVENT = "EVENT_QUEUE_RUN_PERIODIC_EVENT";
@@ -321,7 +321,7 @@ module.exports = {
321
321
  multiTenancyDb,
322
322
  multiTenancyRedis,
323
323
  runEventCombinationForTenant,
324
- _: {
324
+ __: {
325
325
  _singleTenantDb,
326
326
  _multiTenancyRedis,
327
327
  _multiTenancyDb,
@@ -5,7 +5,7 @@ const cds = require("@sap/cds");
5
5
  const config = require("../config");
6
6
  const EventQueueError = require("../EventQueueError");
7
7
 
8
- const COMPONENT_NAME = "eventQueue/WorkerQueue";
8
+ const COMPONENT_NAME = "/eventQueue/WorkerQueue";
9
9
  const NANO_TO_MS = 1e6;
10
10
  const THRESHOLD = {
11
11
  INFO: 35 * 1000,
@@ -8,7 +8,7 @@ const config = require("../config");
8
8
  const subdomainCache = {};
9
9
 
10
10
  const VERROR_CLUSTER_NAME = "ExecuteInNewTransactionError";
11
- const COMPONENT_NAME = "eventQueue/cdsHelper";
11
+ const COMPONENT_NAME = "/eventQueue/cdsHelper";
12
12
 
13
13
  /**
14
14
  * Execute logic in a new managed CDS transaction context, auto-handling commit, rollback and error/exception situations.
@@ -5,7 +5,7 @@ const cds = require("@sap/cds");
5
5
  const { broadcastEvent } = require("../redisPubSub");
6
6
  const config = require("./../config");
7
7
 
8
- const COMPONENT_NAME = "eventQueue/shared/eventScheduler";
8
+ const COMPONENT_NAME = "/eventQueue/shared/eventScheduler";
9
9
 
10
10
  let instance;
11
11
  class EventScheduler {
@@ -5,7 +5,7 @@ const redis = require("redis");
5
5
  const { getEnvInstance } = require("./env");
6
6
  const EventQueueError = require("../EventQueueError");
7
7
 
8
- const COMPONENT_NAME = "eventQueue/shared/redis";
8
+ const COMPONENT_NAME = "/eventQueue/shared/redis";
9
9
 
10
10
  let mainClientPromise;
11
11
  const subscriberChannelClientPromise = {};