@cap-js-community/event-queue 1.7.1 → 1.7.3

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/cds-plugin.js CHANGED
@@ -4,6 +4,7 @@ const cds = require("@sap/cds");
4
4
  const cdsPackage = require("@sap/cds/package.json");
5
5
 
6
6
  const eventQueue = require("./src");
7
+ const EventQueueError = require("./src/EventQueueError");
7
8
  const COMPONENT_NAME = "/eventQueue/plugin";
8
9
  const SERVE_COMMAND = "serve";
9
10
 
@@ -18,5 +19,10 @@ if ((doLegacyBuildDetection && isBuild) || (!doLegacyBuildDetection && !isServe)
18
19
  }
19
20
 
20
21
  if (Object.keys(cds.env.eventQueue ?? {}).length) {
21
- module.exports = eventQueue.initialize().catch((err) => cds.log(COMPONENT_NAME).error(err));
22
+ module.exports = eventQueue.initialize().catch((err) => {
23
+ if (EventQueueError.isRedisConnectionFailure(err) && eventQueue.config.crashOnRedisUnavailable) {
24
+ throw err;
25
+ }
26
+ cds.log(COMPONENT_NAME).error(err);
27
+ });
22
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
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",
@@ -41,7 +41,7 @@ const ERROR_CODES_META = {
41
41
  message: "error during create client with redis-cache service",
42
42
  },
43
43
  [ERROR_CODES.REDIS_LOCAL_NO_RECONNECT]: {
44
- message: "disabled reconnect, because we are not running on cloud foundry",
44
+ message: "disabled reconnect, because not running on cloud foundry",
45
45
  },
46
46
  [ERROR_CODES.MISSING_TABLE_DEFINITION]: {
47
47
  message: "Could not find table in csn. Make sure the provided table name is correct and the table is known by CDS.",
@@ -135,7 +135,7 @@ class EventQueueError extends VError {
135
135
  return new EventQueueError(
136
136
  {
137
137
  name: ERROR_CODES.REDIS_CREATE_CLIENT,
138
- cause: err,
138
+ ...(err && { cause: err }),
139
139
  },
140
140
  message
141
141
  );
@@ -325,6 +325,10 @@ class EventQueueError extends VError {
325
325
  message
326
326
  );
327
327
  }
328
+
329
+ static isRedisConnectionFailure(err) {
330
+ return err instanceof VError && err.name === ERROR_CODES.REDIS_CREATE_CLIENT;
331
+ }
328
332
  }
329
333
 
330
334
  module.exports = EventQueueError;
package/src/config.js CHANGED
@@ -77,6 +77,7 @@ class Config {
77
77
  #unsubscribedTenants = {};
78
78
  #cronTimezone;
79
79
  #publishEventBlockList;
80
+ #crashOnRedisUnavailable;
80
81
  static #instance;
81
82
  constructor() {
82
83
  this.#logger = cds.log(COMPONENT_NAME);
@@ -510,6 +511,14 @@ class Config {
510
511
  this.#publishEventBlockList = value;
511
512
  }
512
513
 
514
+ get crashOnRedisUnavailable() {
515
+ return this.#crashOnRedisUnavailable;
516
+ }
517
+
518
+ set crashOnRedisUnavailable(value) {
519
+ this.#crashOnRedisUnavailable = value;
520
+ }
521
+
513
522
  set globalTxTimeout(value) {
514
523
  this.#globalTxTimeout = value;
515
524
  }
package/src/index.d.ts CHANGED
@@ -182,6 +182,8 @@ declare class Config {
182
182
  hasEventAfterCommitFlag(type: string, subType: string): boolean;
183
183
  _checkRedisIsBound(): boolean;
184
184
  checkRedisEnabled(): boolean;
185
+ publishEventBlockList(): boolean;
186
+ crashOnRedisUnavailable(): boolean;
185
187
  attachConfigChangeHandler(): void;
186
188
  attachRedisUnsubscribeHandler(): void;
187
189
  executeUnsubscribeHandlers(tenantId: string): void;
package/src/initialize.js CHANGED
@@ -16,6 +16,7 @@ const eventQueueAsOutbox = require("./outbox/eventQueueAsOutbox");
16
16
  const { getAllTenantIds } = require("./shared/cdsHelper");
17
17
  const { EventProcessingStatus } = require("./constants");
18
18
  const distributedLock = require("./shared/distributedLock");
19
+ const EventQueueError = require("./EventQueueError");
19
20
 
20
21
  const readFileAsync = promisify(fs.readFile);
21
22
 
@@ -39,6 +40,7 @@ const CONFIG_VARS = [
39
40
  ["enableCAPTelemetry", false],
40
41
  ["cronTimezone", null],
41
42
  ["publishEventBlockList", true],
43
+ ["crashOnRedisUnavailable", false],
42
44
  ];
43
45
 
44
46
  /**
@@ -61,6 +63,7 @@ const CONFIG_VARS = [
61
63
  * @param {boolean} [options.enableCAPTelemetry=false] - Enable telemetry for CAP.
62
64
  * @param {string} [options.cronTimezone=null] - Default timezone for cron jobs.
63
65
  * @param {string} [options.publishEventBlockList=true] - If redis is available event blocklist is distributed to all application instances
66
+ * @param {string} [options.crashOnRedisUnavailable=true] - If enabled an error is thrown if the redis connection check is not successful
64
67
  */
65
68
  const initialize = async (options = {}) => {
66
69
  if (config.initialized) {
@@ -71,6 +74,12 @@ const initialize = async (options = {}) => {
71
74
  mixConfigVarsWithEnv(options);
72
75
 
73
76
  const logger = cds.log(COMPONENT);
77
+ if (!config.useAsCAPOutbox && !config.configFilePath) {
78
+ logger.info(
79
+ "Event queue initialization skipped: no configFilePath provided, and event queue is not configured as a CAP outbox."
80
+ );
81
+ }
82
+
74
83
  const redisEnabled = config.checkRedisEnabled();
75
84
  let resolveFn;
76
85
  let initFinished = new Promise((resolve) => (resolveFn = resolve));
@@ -83,6 +92,9 @@ const initialize = async (options = {}) => {
83
92
  });
84
93
  if (redisEnabled) {
85
94
  config.redisEnabled = await redis.connectionCheck(config.redisOptions);
95
+ if (!config.redisEnabled && config.crashOnRedisUnavailable) {
96
+ throw EventQueueError.redisConnectionFailure();
97
+ }
86
98
  }
87
99
  config.fileContent = await readConfigFromFile(config.configFilePath);
88
100
 
@@ -133,17 +145,21 @@ const registerEventProcessors = () => {
133
145
 
134
146
  const errorHandler = (err) => cds.log(COMPONENT).error("error during init runner", err);
135
147
 
136
- if (!config.isMultiTenancy) {
137
- runner.singleTenant().catch(errorHandler);
138
- return;
139
- }
140
-
141
148
  if (config.redisEnabled) {
142
149
  initEventQueueRedisSubscribe();
143
150
  config.attachConfigChangeHandler();
144
- runner.multiTenancyRedis().catch(errorHandler);
145
- } else {
151
+ if (config.isMultiTenancy) {
152
+ runner.multiTenancyRedis().catch(errorHandler);
153
+ } else {
154
+ runner.singleTenantRedis().catch(errorHandler);
155
+ }
156
+ return;
157
+ }
158
+
159
+ if (config.isMultiTenancy) {
146
160
  runner.multiTenancyDb().catch(errorHandler);
161
+ } else {
162
+ runner.singleTenantDb().catch(errorHandler);
147
163
  }
148
164
  };
149
165
 
@@ -122,7 +122,7 @@ const _determineChangedCron = (existingEventsCron) => {
122
122
  utc: config.utc,
123
123
  ...(config.useCronTimezone && { tz: eventConfig.cronTimezone }),
124
124
  });
125
- return cronExpression.next().getTime() - eventStartAfter.getTime() > 30 * 1000; // report as changed if diff created than 30 seconds
125
+ return Math.abs(cronExpression.next().getTime() - eventStartAfter.getTime()) > 30 * 1000; // report as changed if diff created than 30 seconds
126
126
  });
127
127
  };
128
128
 
@@ -133,12 +133,13 @@ const _insertPeriodEvents = async (tx, events, now) => {
133
133
  const eventsToBeInserted = events.map((event) => {
134
134
  const base = { type: event.type, subType: event.subType };
135
135
  let startTime = now;
136
- if (event.cron) {
136
+ const config = eventConfig.getEventConfig(event.type, event.subType);
137
+ if (config.cron) {
137
138
  startTime = cronParser
138
- .parseExpression(event.cron, {
139
+ .parseExpression(config.cron, {
139
140
  currentDate: now,
140
- utc: event.utc,
141
- ...(event.useCronTimezone && { tz: eventConfig.cronTimezone }),
141
+ utc: config.utc,
142
+ ...(config.useCronTimezone && { tz: eventConfig.cronTimezone }),
142
143
  })
143
144
  .next();
144
145
  }
@@ -27,12 +27,14 @@ let OFFSET_FIRST_RUN = 10 * 1000;
27
27
  let tenantIdHash;
28
28
  let singleRunDone;
29
29
 
30
- const singleTenant = () => _scheduleFunction(_checkPeriodicEventsSingleTenantOneTime, _singleTenantDb);
30
+ const singleTenantDb = () => _scheduleFunction(_checkPeriodicEventsSingleTenantOneTime, _singleTenantDb);
31
31
 
32
32
  const multiTenancyDb = () => _scheduleFunction(async () => {}, _multiTenancyDb);
33
33
 
34
34
  const multiTenancyRedis = () => _scheduleFunction(async () => {}, _multiTenancyRedis);
35
35
 
36
+ const singleTenantRedis = () => _scheduleFunction(_checkPeriodicEventsSingleTenantOneTime, _singleTenantRedis);
37
+
36
38
  const _scheduleFunction = async (singleRunFn, periodicFn) => {
37
39
  const logger = cds.log(COMPONENT_NAME);
38
40
  const eventsForAutomaticRun = eventQueueConfig.allEvents;
@@ -300,6 +302,54 @@ const _singleTenantDb = async () => {
300
302
  );
301
303
  };
302
304
 
305
+ const _singleTenantRedis = async () => {
306
+ const id = cds.utils.uuid();
307
+ const logger = cds.log(COMPONENT_NAME);
308
+ try {
309
+ // NOTE: do checks for open events on one app instance distribute from this instance to all others
310
+ const dummyContext = new cds.EventContext({});
311
+ const couldAcquireLock = await trace(
312
+ dummyContext,
313
+ "acquire-lock-master-runner",
314
+ async () => {
315
+ return await distributedLock.acquireLock(dummyContext, EVENT_QUEUE_RUN_REDIS_CHECK, {
316
+ expiryTime: eventQueueConfig.runInterval * 0.95,
317
+ tenantScoped: false,
318
+ });
319
+ },
320
+ { newRootSpan: true }
321
+ );
322
+ if (!couldAcquireLock) {
323
+ return;
324
+ }
325
+
326
+ await trace(
327
+ { id },
328
+ "get-openEvents-and-publish",
329
+ async () => {
330
+ return await cds.tx({}, async (tx) => {
331
+ const entries = await openEvents.getOpenQueueEntries(tx);
332
+ logger.info("broadcasting events for run", {
333
+ entries: entries.length,
334
+ });
335
+ if (!entries.length) {
336
+ return;
337
+ }
338
+ // Do not wait until this is finished - as broadcastEvent has a retry mechanism and can delay this loop
339
+ redisPub.broadcastEvent(null, entries).catch((err) => {
340
+ logger.error("broadcasting event failed", err, {
341
+ entries: entries.length,
342
+ });
343
+ });
344
+ });
345
+ },
346
+ { newRootSpan: true }
347
+ );
348
+ } catch (err) {
349
+ logger.info("executing event queue run for single tenant via redis", err);
350
+ }
351
+ };
352
+
303
353
  const _acquireRunId = async (context) => {
304
354
  let runId = randomUUID();
305
355
  const couldSetValue = await distributedLock.setValueWithExpire(context, EVENT_QUEUE_RUN_ID, runId, {
@@ -401,9 +451,33 @@ const _multiTenancyPeriodicEvents = async (tenantIds) => {
401
451
  }
402
452
  };
403
453
 
404
- const _checkPeriodicEventsSingleTenantOneTime = async () =>
405
- eventQueueConfig.updatePeriodicEvents &&
406
- cds.tx({}, async (tx) => await periodicEvents.checkAndInsertPeriodicEvents(tx.context));
454
+ const _checkPeriodicEventsSingleTenantOneTime = async () => {
455
+ const logger = cds.log(COMPONENT_NAME);
456
+ if (!eventQueueConfig.updatePeriodicEvents || !eventQueueConfig.periodicEvents.length) {
457
+ logger.info("updating of periodic events is disabled or no periodic events configured", {
458
+ updateEnabled: eventQueueConfig.updatePeriodicEvents,
459
+ events: eventQueueConfig.periodicEvents.length,
460
+ });
461
+ return;
462
+ }
463
+
464
+ const dummyContext = new cds.EventContext({});
465
+ return await trace(
466
+ dummyContext,
467
+ "update-periodic-events",
468
+ async () => {
469
+ const couldAcquireLock = await distributedLock.acquireLock(dummyContext, EVENT_QUEUE_UPDATE_PERIODIC_EVENTS, {
470
+ expiryTime: 60 * 1000,
471
+ tenantScoped: false,
472
+ });
473
+ if (!couldAcquireLock) {
474
+ return;
475
+ }
476
+ return await cds.tx({}, async (tx) => await periodicEvents.checkAndInsertPeriodicEvents(tx.context));
477
+ },
478
+ { newRootSpan: true }
479
+ );
480
+ };
407
481
 
408
482
  const _checkPeriodicEventsSingleTenant = async (context) => {
409
483
  const logger = cds.log(COMPONENT_NAME);
@@ -429,12 +503,14 @@ const _checkPeriodicEventsSingleTenant = async (context) => {
429
503
  };
430
504
 
431
505
  module.exports = {
432
- singleTenant,
506
+ singleTenantDb,
433
507
  multiTenancyDb,
434
508
  multiTenancyRedis,
509
+ singleTenantRedis,
435
510
  __: {
436
511
  _singleTenantDb,
437
512
  _multiTenancyRedis,
513
+ _singleTenantRedis,
438
514
  _multiTenancyDb,
439
515
  _calculateOffsetForFirstRun,
440
516
  _acquireRunId,
@@ -87,6 +87,10 @@ const _getNewAuthInfo = async (tenantId) => {
87
87
  };
88
88
 
89
89
  const getAuthInfo = async (tenantId) => {
90
+ if (!tenantId) {
91
+ return null;
92
+ }
93
+
90
94
  if (!cds.requires?.auth?.credentials) {
91
95
  return null; // no credentials not authInfo
92
96
  }
@@ -50,25 +50,27 @@ const _createClientBase = (redisOptions) => {
50
50
  }
51
51
  };
52
52
 
53
- const createClientAndConnect = async (options, errorHandlerCreateClient) => {
53
+ const createClientAndConnect = async (options, errorHandlerCreateClient, isConnectionCheck) => {
54
54
  try {
55
55
  const client = _createClientBase(options);
56
- await client.connect();
57
- client.on("error", (err) => {
58
- const dateNow = Date.now();
59
- if (dateNow - lastErrorLog > LOG_AFTER_SEC * 1000) {
60
- cds.log(COMPONENT_NAME).error("error from redis client for pub/sub failed", err);
61
- lastErrorLog = dateNow;
62
- }
63
- });
56
+ if (!isConnectionCheck) {
57
+ client.on("error", (err) => {
58
+ const dateNow = Date.now();
59
+ if (dateNow - lastErrorLog > LOG_AFTER_SEC * 1000) {
60
+ cds.log(COMPONENT_NAME).error("error from redis client for pub/sub failed", err);
61
+ lastErrorLog = dateNow;
62
+ }
63
+ });
64
64
 
65
- client.on("reconnecting", () => {
66
- const dateNow = Date.now();
67
- if (dateNow - lastErrorLog > LOG_AFTER_SEC * 1000) {
68
- cds.log(COMPONENT_NAME).info("redis client trying reconnect...");
69
- lastErrorLog = dateNow;
70
- }
71
- });
65
+ client.on("reconnecting", () => {
66
+ const dateNow = Date.now();
67
+ if (dateNow - lastErrorLog > LOG_AFTER_SEC * 1000) {
68
+ cds.log(COMPONENT_NAME).info("redis client trying reconnect...");
69
+ lastErrorLog = dateNow;
70
+ }
71
+ });
72
+ }
73
+ await client.connect();
72
74
  return client;
73
75
  } catch (err) {
74
76
  errorHandlerCreateClient(err);
@@ -119,7 +121,7 @@ const _resilientClientClose = async (client) => {
119
121
 
120
122
  const connectionCheck = async (options) => {
121
123
  return new Promise((resolve, reject) => {
122
- createClientAndConnect(options, reject)
124
+ createClientAndConnect(options, reject, true)
123
125
  .then((client) => {
124
126
  if (client) {
125
127
  _resilientClientClose(client);
@@ -131,7 +133,10 @@ const connectionCheck = async (options) => {
131
133
  .catch(reject);
132
134
  })
133
135
  .then(() => true)
134
- .catch(() => false);
136
+ .catch((err) => {
137
+ cds.log(COMPONENT_NAME).error("Redis connection check failed! Falling back to NO_REDIS mode", err);
138
+ return false;
139
+ });
135
140
  };
136
141
 
137
142
  module.exports = {