@cap-js-community/event-queue 1.8.0 → 1.8.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": "1.8.0",
3
+ "version": "1.8.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",
@@ -50,15 +50,15 @@
50
50
  "yaml": "^2.6.1"
51
51
  },
52
52
  "devDependencies": {
53
- "@cap-js/hana": "^1.3.0",
54
- "@cap-js/sqlite": "^1.7.3",
55
- "@sap/cds": "^8.4.2",
56
- "@sap/cds-dk": "^8.4.2",
53
+ "@cap-js/hana": "^1.5.2",
54
+ "@cap-js/sqlite": "^1.7.8",
55
+ "@sap/cds": "^8.6.0",
56
+ "@sap/cds-dk": "^8.6.1",
57
57
  "eslint": "^8.57.0",
58
58
  "eslint-config-prettier": "^9.1.0",
59
59
  "eslint-plugin-jest": "^28.6.0",
60
60
  "eslint-plugin-node": "^11.1.0",
61
- "express": "^4.21.0",
61
+ "express": "^4.21.2",
62
62
  "hdb": "^0.19.10",
63
63
  "jest": "^29.7.0",
64
64
  "prettier": "^2.8.8",
@@ -75,13 +75,19 @@
75
75
  "disableRedis": false
76
76
  },
77
77
  "[test]": {
78
- "isRunnerDeactivated": true,
78
+ "isEventQueueActive": false,
79
79
  "registerAsEventProcessor": false,
80
80
  "updatePeriodicEvents": false,
81
81
  "insertEventsBeforeCommit": false
82
82
  }
83
83
  },
84
84
  "requires": {
85
+ "redis-eventQueue": {
86
+ "options": {},
87
+ "vcap": {
88
+ "label": "redis-cache"
89
+ }
90
+ },
85
91
  "event-queue": {
86
92
  "model": "@cap-js-community/event-queue"
87
93
  }
@@ -24,6 +24,7 @@ const ERROR_CODES = {
24
24
  NOT_ALLOWED_PRIORITY: "NOT_ALLOWED_PRIORITY",
25
25
  APP_NAMES_FORMAT: "APP_NAMES_FORMAT",
26
26
  APP_INSTANCES_FORMAT: "APP_INSTANCES_FORMAT",
27
+ MULTI_INSTANCE_PROCESSING_NOT_ALLOWED: "MULTI_INSTANCE_PROCESSING_NOT_ALLOWED",
27
28
  };
28
29
 
29
30
  const ERROR_CODES_META = {
@@ -91,6 +92,9 @@ const ERROR_CODES_META = {
91
92
  [ERROR_CODES.INTERVAL_AND_CRON]: {
92
93
  message: "For periodic events only the cron or interval parameter can be defined!",
93
94
  },
95
+ [ERROR_CODES.MULTI_INSTANCE_PROCESSING_NOT_ALLOWED]: {
96
+ message: "The config multiInstanceProcessing is currently only allowed for ad-hoc events and single-tenant-apps.",
97
+ },
94
98
  };
95
99
 
96
100
  class EventQueueError extends VError {
@@ -326,6 +330,17 @@ class EventQueueError extends VError {
326
330
  );
327
331
  }
328
332
 
333
+ static multiInstanceProcessingNotAllowed(type, subType) {
334
+ const { message } = ERROR_CODES_META[ERROR_CODES.MULTI_INSTANCE_PROCESSING_NOT_ALLOWED];
335
+ return new EventQueueError(
336
+ {
337
+ name: ERROR_CODES.MULTI_INSTANCE_PROCESSING_NOT_ALLOWED,
338
+ info: { type, subType },
339
+ },
340
+ message
341
+ );
342
+ }
343
+
329
344
  static isRedisConnectionFailure(err) {
330
345
  return err instanceof VError && err.name === ERROR_CODES.REDIS_CREATE_CLIENT;
331
346
  }
@@ -3,7 +3,7 @@
3
3
  const cds = require("@sap/cds");
4
4
  const cronParser = require("cron-parser");
5
5
 
6
- const { executeInNewTransaction, TriggerRollback } = require("./shared/cdsHelper");
6
+ const { executeInNewTransaction } = require("./shared/cdsHelper");
7
7
  const { EventProcessingStatus, TransactionMode } = require("./constants");
8
8
  const distributedLock = require("./shared/distributedLock");
9
9
  const EventQueueError = require("./EventQueueError");
@@ -61,8 +61,7 @@ class EventQueueProcessorBase {
61
61
  this.__parallelEventProcessing = LIMIT_PARALLEL_EVENT_PROCESSING;
62
62
  }
63
63
  this.#retryFailedAfter = this.#eventConfig.retryFailedAfter ?? DEFAULT_RETRY_AFTER;
64
- // NOTE: keep the feature, this might be needed again
65
- this.__concurrentEventProcessing = false;
64
+ this.__concurrentEventProcessing = this.#eventConfig.multiInstanceProcessing;
66
65
  this.__startTime = this.#eventConfig.startTime ?? new Date();
67
66
  this.__retryAttempts = this.#isPeriodic ? 1 : this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS;
68
67
  this.__selectMaxChunkSize = this.#eventConfig.selectMaxChunkSize ?? SELECT_LIMIT_EVENTS_PER_TICK;
@@ -786,7 +785,7 @@ class EventQueueProcessorBase {
786
785
  await executeInNewTransaction(this.context, "error-hookForExceededEvents", async (tx) =>
787
786
  this.#persistEventQueueStatusForExceeded(tx, [exceededEvent], EventProcessingStatus.Error)
788
787
  );
789
- throw new TriggerRollback();
788
+ await tx.rollback();
790
789
  }
791
790
  }
792
791
  );
@@ -918,7 +917,8 @@ class EventQueueProcessorBase {
918
917
  return await trace(this.baseContext, "acquire-lock", async () => {
919
918
  const lockAcquired = await distributedLock.acquireLock(
920
919
  this.context,
921
- [this.#eventType, this.#eventSubType].join("##")
920
+ [this.#eventType, this.#eventSubType].join("##"),
921
+ { keepTrackOfLock: true }
922
922
  );
923
923
  if (!lockAcquired) {
924
924
  this.logger.debug("no lock available, exit processing", {
package/src/config.js CHANGED
@@ -113,7 +113,7 @@ class Config {
113
113
  }
114
114
 
115
115
  _checkRedisIsBound() {
116
- return !!this.#env.redisCredentialsFromEnv;
116
+ return !!this.#env.redisRequires?.credentials;
117
117
  }
118
118
 
119
119
  shouldBeProcessedInThisApplication(type, subType) {
@@ -445,6 +445,11 @@ class Config {
445
445
  if (!event.interval || event.interval <= MIN_INTERVAL_SEC) {
446
446
  throw EventQueueError.invalidInterval(event.type, event.subType, event.interval);
447
447
  }
448
+
449
+ if (event.multiInstanceProcessing) {
450
+ throw EventQueueError.multiInstanceProcessingNotAllowed(event.type, event.subType);
451
+ }
452
+
448
453
  this.#basicEventValidation(event);
449
454
  }
450
455
 
@@ -453,6 +458,11 @@ class Config {
453
458
  if (eventMap[key] && !eventMap[key].isPeriodic) {
454
459
  throw EventQueueError.duplicateEventRegistration(event.type, event.subType);
455
460
  }
461
+
462
+ if (this.isMultiTenancy && event.multiInstanceProcessing) {
463
+ throw EventQueueError.multiInstanceProcessingNotAllowed(event.type, event.subType);
464
+ }
465
+
456
466
  this.#basicEventValidation(event);
457
467
  }
458
468
 
package/src/index.d.ts CHANGED
@@ -11,6 +11,11 @@ export declare const EventProcessingStatus: {
11
11
  declare type EventProcessingStatusKeysType = keyof typeof EventProcessingStatus;
12
12
  export declare type EventProcessingStatusType = (typeof EventProcessingStatus)[EventProcessingStatusKeysType];
13
13
 
14
+ export declare const TenantIdCheckTypes: {
15
+ getAllTenantIds: "getAllTenantIds";
16
+ getTokenInfo: "getTokenInfo";
17
+ };
18
+
14
19
  export declare const TransactionMode: {
15
20
  isolated: "isolated";
16
21
  alwaysCommit: "alwaysCommit";
@@ -8,7 +8,7 @@ const config = require("./config");
8
8
  const { TransactionMode, EventProcessingStatus } = require("./constants");
9
9
  const { limiter } = require("./shared/common");
10
10
 
11
- const { executeInNewTransaction, TriggerRollback } = require("./shared/cdsHelper");
11
+ const { executeInNewTransaction } = require("./shared/cdsHelper");
12
12
  const trace = require("./shared/openTelemetry");
13
13
 
14
14
  const COMPONENT_NAME = "/eventQueue/processEventQueue";
@@ -65,7 +65,7 @@ const processEventQueue = async (context, eventType, eventSubType, startTime = n
65
65
  eventTypeInstance.handleErrorDuringProcessing(err, queueEntry);
66
66
  }
67
67
  }
68
- throw new TriggerRollback();
68
+ await tx.rollback();
69
69
  });
70
70
  });
71
71
  await eventTypeInstance.handleExceededEvents();
@@ -89,7 +89,7 @@ const processEventQueue = async (context, eventType, eventSubType, startTime = n
89
89
  eventTypeInstance.shouldRollbackTransaction(key)
90
90
  )
91
91
  ) {
92
- throw new TriggerRollback();
92
+ await tx.rollback();
93
93
  }
94
94
  });
95
95
  });
@@ -167,7 +167,8 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
167
167
  } catch (err) {
168
168
  status = EventProcessingStatus.Error;
169
169
  eventTypeInstance.handleErrorDuringPeriodicEventProcessing(err, queueEntry);
170
- throw new TriggerRollback();
170
+ await tx.rollback();
171
+ return;
171
172
  } finally {
172
173
  eventTypeInstance.endPerformanceTracerPeriodicEvents();
173
174
  }
@@ -175,7 +176,7 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
175
176
  eventTypeInstance.transactionMode === TransactionMode.alwaysRollback ||
176
177
  eventTypeInstance.shouldRollbackTransaction(queueEntry.ID)
177
178
  ) {
178
- throw new TriggerRollback();
179
+ await tx.rollback();
179
180
  }
180
181
  });
181
182
  }
@@ -222,7 +223,7 @@ const processEventMap = async (eventTypeInstance) => {
222
223
  eventTypeInstance.statusMapContainsError(statusMap) ||
223
224
  eventTypeInstance.shouldRollbackTransaction(key)
224
225
  ) {
225
- throw new TriggerRollback();
226
+ await tx.rollback();
226
227
  }
227
228
  }
228
229
  );
@@ -5,7 +5,7 @@ const { promisify } = require("util");
5
5
  const cds = require("@sap/cds");
6
6
 
7
7
  const redis = require("../shared/redis");
8
- const { checkLockExistsAndReturnValue } = require("../shared/distributedLock");
8
+ const distributedLock = require("../shared/distributedLock");
9
9
  const config = require("../config");
10
10
  const common = require("../shared/common");
11
11
  const { runEventCombinationForTenant } = require("../runner/runnerHelper");
@@ -67,7 +67,9 @@ const broadcastEvent = async (tenantId, events, forceBroadcast = false) => {
67
67
  for (const { type, subType } of events) {
68
68
  const eventConfig = config.getEventConfig(type, subType);
69
69
  for (let i = 0; i < TRIES_FOR_PUBLISH_PERIODIC_EVENT; i++) {
70
- const result = await checkLockExistsAndReturnValue(context, [type, subType].join("##"));
70
+ const result = eventConfig.multiInstanceProcessing
71
+ ? false
72
+ : await distributedLock.checkLockExistsAndReturnValue(context, [type, subType].join("##"));
71
73
  if (result) {
72
74
  logger.debug("skip publish redis event as no lock is available", {
73
75
  type,
@@ -281,9 +281,11 @@ const _singleTenantDb = async () => {
281
281
  async () => {
282
282
  try {
283
283
  const lockId = `${label}`;
284
- const couldAcquireLock = await distributedLock.acquireLock(context, lockId, {
285
- expiryTime: eventQueueConfig.runInterval * 0.95,
286
- });
284
+ const couldAcquireLock = eventConfig.multiInstanceProcessing
285
+ ? true
286
+ : await distributedLock.acquireLock(context, lockId, {
287
+ expiryTime: eventQueueConfig.runInterval * 0.95,
288
+ });
287
289
  if (!couldAcquireLock) {
288
290
  return;
289
291
  }
@@ -25,7 +25,7 @@ const runEventCombinationForTenant = async (context, type, subType, { skipWorker
25
25
  eventConfig.priority,
26
26
  AsyncResource.bind(async () => {
27
27
  const _exec = async () => {
28
- if (lockId) {
28
+ if (!eventConfig.multiInstanceProcessing && lockId) {
29
29
  const lockAvailable = await distributedLock.acquireLock(context, lockId);
30
30
  if (!lockAvailable) {
31
31
  return;
@@ -62,42 +62,34 @@ async function executeInNewTransaction(context = {}, transactionTag, fn, args, {
62
62
  } catch {
63
63
  /* empty */
64
64
  }
65
- await fn(contextTx, ...parameters);
65
+ const txRollback = contextTx.rollback;
66
+ contextTx.rollback = async () => {
67
+ // tx should not be managed here as we did not open the tx
68
+ // change rollback to no opt - closing tx would cause follow-up usage to fail.
69
+ // the process that opened the tx needs to manage it
70
+ };
71
+ await fn(contextTx, ...parameters).finally(() => (contextTx.rollback = txRollback));
66
72
  }
67
73
  }
68
74
  } catch (err) {
69
- if (!(err instanceof TriggerRollback)) {
70
- if (err instanceof VError) {
71
- Object.assign(err.jse_info, {
72
- newTx: info,
73
- });
74
- throw err;
75
- } else {
76
- throw new VError(
77
- {
78
- name: VERROR_CLUSTER_NAME,
79
- cause: err,
80
- info,
81
- },
82
- "Execution in new transaction failed"
83
- );
84
- }
75
+ if (err instanceof VError) {
76
+ Object.assign(err.jse_info, {
77
+ newTx: info,
78
+ });
79
+ throw err;
80
+ } else {
81
+ throw new VError(
82
+ {
83
+ name: VERROR_CLUSTER_NAME,
84
+ cause: err,
85
+ info,
86
+ },
87
+ "Execution in new transaction failed"
88
+ );
85
89
  }
86
- return false;
87
90
  } finally {
88
91
  logger.debug("Execution in new transaction finished", info);
89
92
  }
90
- return true;
91
- }
92
-
93
- /**
94
- * Error class to be used to force rollback in executionInNewTransaction
95
- * Error will not be logged, as it assumes that error handling has been done before...
96
- */
97
- class TriggerRollback extends VError {
98
- constructor() {
99
- super("Rollback triggered");
100
- }
101
93
  }
102
94
 
103
95
  const getAllTenantIds = async () => {
@@ -122,6 +114,5 @@ const getAllTenantIds = async () => {
122
114
 
123
115
  module.exports = {
124
116
  executeInNewTransaction,
125
- TriggerRollback,
126
117
  getAllTenantIds,
127
118
  };
@@ -5,15 +5,18 @@ const config = require("../config");
5
5
  const cdsHelper = require("./cdsHelper");
6
6
 
7
7
  const KEY_PREFIX = "EVENT_QUEUE";
8
-
9
8
  const existingLocks = {};
10
-
11
9
  const REDIS_COMMAND_OK = "OK";
10
+ const COMPONENT_NAME = "/eventQueue/distributedLock";
12
11
 
13
- const acquireLock = async (context, key, { tenantScoped = true, expiryTime = config.globalTxTimeout } = {}) => {
12
+ const acquireLock = async (
13
+ context,
14
+ key,
15
+ { tenantScoped = true, expiryTime = config.globalTxTimeout, keepTrackOfLock = false } = {}
16
+ ) => {
14
17
  const fullKey = _generateKey(context, tenantScoped, key);
15
18
  if (config.redisEnabled) {
16
- return await _acquireLockRedis(context, fullKey, expiryTime);
19
+ return await _acquireLockRedis(context, fullKey, expiryTime, { keepTrackOfLock });
17
20
  } else {
18
21
  return await _acquireLockDB(context, fullKey, expiryTime);
19
22
  }
@@ -23,13 +26,14 @@ const setValueWithExpire = async (
23
26
  context,
24
27
  key,
25
28
  value,
26
- { tenantScoped = true, expiryTime = config.globalTxTimeout, overrideValue = false } = {}
29
+ { tenantScoped = true, expiryTime = config.globalTxTimeout, overrideValue = false, keepTrackOfLock = false } = {}
27
30
  ) => {
28
31
  const fullKey = _generateKey(context, tenantScoped, key);
29
32
  if (config.redisEnabled) {
30
33
  return await _acquireLockRedis(context, fullKey, expiryTime, {
31
34
  value,
32
35
  overrideValue,
36
+ keepTrackOfLock,
33
37
  });
34
38
  } else {
35
39
  return await _acquireLockDB(context, fullKey, expiryTime, {
@@ -57,14 +61,19 @@ const checkLockExistsAndReturnValue = async (context, key, { tenantScoped = true
57
61
  }
58
62
  };
59
63
 
60
- const _acquireLockRedis = async (context, fullKey, expiryTime, { value = "true", overrideValue = false } = {}) => {
64
+ const _acquireLockRedis = async (
65
+ context,
66
+ fullKey,
67
+ expiryTime,
68
+ { value = "true", overrideValue = false, keepTrackOfLock } = {}
69
+ ) => {
61
70
  const client = await redis.createMainClientAndConnect(config.redisOptions);
62
71
  const result = await client.set(fullKey, value, {
63
72
  PX: Math.round(expiryTime),
64
73
  ...(overrideValue ? null : { NX: true }),
65
74
  });
66
75
  const isOk = result === REDIS_COMMAND_OK;
67
- if (isOk) {
76
+ if (isOk && keepTrackOfLock) {
68
77
  existingLocks[fullKey] = 1;
69
78
  }
70
79
  return isOk;
@@ -146,7 +155,21 @@ const _generateKey = (context, tenantScoped, key) => {
146
155
  };
147
156
 
148
157
  const shutdownHandler = async () => {
149
- await Promise.allSettled(Object.keys(existingLocks).map((key) => _releaseLockRedis(null, key)));
158
+ const logger = cds.log(COMPONENT_NAME);
159
+ logger.info("received shutdown event, trying to release all locks", {
160
+ numberOfLocks: Object.keys(existingLocks).length,
161
+ });
162
+ const result = await Promise.allSettled(
163
+ Object.keys(existingLocks).map(async (key) => {
164
+ await _releaseLockRedis(null, key);
165
+ logger.info("lock released", { key });
166
+ })
167
+ );
168
+ const errors = result.filter((promise) => promise.reason);
169
+ logger.info("releasing locks finished ", {
170
+ numberOfErrors: errors.length,
171
+ ...(errors.length && { firstError: errors[0] }),
172
+ });
150
173
  };
151
174
 
152
175
  module.exports = {
package/src/shared/env.js CHANGED
@@ -1,25 +1,24 @@
1
1
  "use strict";
2
2
 
3
+ const cds = require("@sap/cds");
4
+
3
5
  let instance;
4
6
 
5
7
  class Env {
6
- #vcapServices;
7
8
  #vcapApplication;
8
9
  #vcapApplicationInstance;
9
10
 
10
11
  constructor() {
11
12
  try {
12
- this.#vcapServices = JSON.parse(process.env.VCAP_SERVICES);
13
13
  this.#vcapApplication = JSON.parse(process.env.VCAP_APPLICATION);
14
14
  } catch {
15
- this.#vcapServices = {};
16
15
  this.#vcapApplication = {};
17
16
  }
18
17
  this.#vcapApplicationInstance = Number(process.env.CF_INSTANCE_INDEX);
19
18
  }
20
19
 
21
- get redisCredentialsFromEnv() {
22
- return this.#vcapServices["redis-cache"]?.[0]?.credentials;
20
+ get redisRequires() {
21
+ return cds.requires["redis-eventQueue"] ?? cds.requires["redis"];
23
22
  }
24
23
 
25
24
  get applicationName() {
@@ -30,14 +29,6 @@ class Env {
30
29
  return this.#vcapApplicationInstance;
31
30
  }
32
31
 
33
- set vcapServices(value) {
34
- this.#vcapServices = value;
35
- }
36
-
37
- get vcapServices() {
38
- return this.#vcapServices;
39
- }
40
-
41
32
  set applicationInstance(value) {
42
33
  this.#vcapApplicationInstance = value;
43
34
  }
@@ -27,24 +27,30 @@ const createMainClientAndConnect = (options) => {
27
27
  return mainClientPromise;
28
28
  };
29
29
 
30
- const _createClientBase = (redisOptions) => {
30
+ const _createClientBase = (redisOptions = {}) => {
31
31
  const env = getEnvInstance();
32
32
  try {
33
- const credentials = env.redisCredentialsFromEnv;
34
- const redisIsCluster = credentials.cluster_mode;
35
- const url = credentials.uri.replace(/(?<=rediss:\/\/)[\w-]+?(?=:)/, "");
36
- if (redisIsCluster) {
33
+ const { credentials, options } = env.redisRequires;
34
+ const socket = Object.assign(
35
+ {
36
+ host: credentials.hostname,
37
+ tls: !!credentials.tls,
38
+ port: credentials.port,
39
+ },
40
+ options?.socket,
41
+ redisOptions.socket
42
+ );
43
+ const socketOptions = Object.assign({}, options, redisOptions, {
44
+ password: redisOptions.password ?? options.password ?? credentials.password,
45
+ socket,
46
+ });
47
+ if (credentials.cluster_mode) {
37
48
  return redis.createCluster({
38
- rootNodes: [{ url }],
39
- // https://github.com/redis/node-redis/issues/1782
40
- defaults: {
41
- password: credentials.password,
42
- socket: { tls: credentials.tls },
43
- ...redisOptions,
44
- },
49
+ rootNodes: [socketOptions],
50
+ defaults: socketOptions,
45
51
  });
46
52
  }
47
- return redis.createClient({ url, ...redisOptions });
53
+ return redis.createClient(socketOptions);
48
54
  } catch (err) {
49
55
  throw EventQueueError.redisConnectionFailure(err);
50
56
  }