@cap-js-community/event-queue 1.6.6 → 1.7.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/cds-plugin.js CHANGED
@@ -1,10 +1,22 @@
1
1
  "use strict";
2
2
 
3
3
  const cds = require("@sap/cds");
4
+ const cdsPackage = require("@sap/cds/package.json");
4
5
 
5
6
  const eventQueue = require("./src");
6
7
  const COMPONENT_NAME = "/eventQueue/plugin";
8
+ const SERVE_COMMAND = "serve";
7
9
 
8
- if (!cds.build?.register && Object.keys(cds.env.eventQueue ?? {}).length) {
10
+ const isServe = cds.cli?.command === SERVE_COMMAND;
11
+ const isBuild = cds.build?.register;
12
+ // NOTE: for sap/cds < 8.2.3 there was no consistent way to detect cds is running as a server, not for build, compile,
13
+ // etc...
14
+ const doLegacyBuildDetection =
15
+ cdsPackage.version.localeCompare("8.2.3", undefined, { numeric: true, sensitivity: "base" }) < 0;
16
+ if ((doLegacyBuildDetection && isBuild) || (!doLegacyBuildDetection && !isServe)) {
17
+ return;
18
+ }
19
+
20
+ if (Object.keys(cds.env.eventQueue ?? {}).length) {
9
21
  module.exports = eventQueue.initialize().catch((err) => cds.log(COMPONENT_NAME).error(err));
10
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "1.6.6",
3
+ "version": "1.7.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",
@@ -43,22 +43,23 @@
43
43
  "node": ">=18"
44
44
  },
45
45
  "dependencies": {
46
- "@sap/xssec": "^4.2.1",
46
+ "@sap/xssec": "^4.2.4",
47
+ "cron-parser": "^4.9.0",
47
48
  "redis": "^4.7.0",
48
49
  "verror": "^1.10.1",
49
- "yaml": "^2.5.0"
50
+ "yaml": "^2.5.1"
50
51
  },
51
52
  "devDependencies": {
52
- "@cap-js/hana": "^1.1.1",
53
+ "@cap-js/hana": "^1.3.0",
53
54
  "@cap-js/sqlite": "^1.7.3",
54
- "@sap/cds": "^8.1.0",
55
- "@sap/cds-dk": "^8.1.0",
55
+ "@sap/cds": "^8.3.0",
56
+ "@sap/cds-dk": "^8.3.0",
56
57
  "eslint": "^8.57.0",
57
58
  "eslint-config-prettier": "^9.1.0",
58
59
  "eslint-plugin-jest": "^28.6.0",
59
60
  "eslint-plugin-node": "^11.1.0",
60
- "express": "^4.18.2",
61
- "hdb": "^0.19.7",
61
+ "express": "^4.21.0",
62
+ "hdb": "^0.19.10",
62
63
  "jest": "^29.7.0",
63
64
  "prettier": "^2.8.8",
64
65
  "sqlite3": "^5.1.7"
@@ -74,9 +75,10 @@
74
75
  "disableRedis": false
75
76
  },
76
77
  "[test]": {
77
- "registerAsEventProcessor": false,
78
78
  "isRunnerDeactivated": true,
79
- "updatePeriodicEvents": false
79
+ "registerAsEventProcessor": false,
80
+ "updatePeriodicEvents": false,
81
+ "insertEventsBeforeCommit": false
80
82
  }
81
83
  },
82
84
  "requires": {
@@ -13,12 +13,17 @@ const ERROR_CODES = {
13
13
  TYPE_MISMATCH_TABLE: "TYPE_MISMATCH_TABLE",
14
14
  NO_VALID_DATE: "NO_VALID_DATE",
15
15
  INVALID_INTERVAL: "INVALID_INTERVAL",
16
+ CANT_PARSE_CRON: "CANT_PARSE_CRON",
17
+ CRON_INTERVAL: "CRON_INTERVAL",
18
+ NO_INTERVAL_OR_CRON: "NO_INTERVAL_OR_CRON",
19
+ INTERVAL_AND_CRON: "INTERVAL_AND_CRON",
16
20
  MISSING_IMPL: "MISSING_IMPL",
17
21
  DUPLICATE_EVENT_REGISTRATION: "DUPLICATE_EVENT_REGISTRATION",
18
22
  NO_MANUEL_INSERT_OF_PERIODIC: "NO_MANUEL_INSERT_OF_PERIODIC",
19
23
  LOAD_HIGHER_THAN_LIMIT: "LOAD_HIGHER_THAN_LIMIT",
20
24
  NOT_ALLOWED_PRIORITY: "NOT_ALLOWED_PRIORITY",
21
25
  APP_NAMES_FORMAT: "APP_NAMES_FORMAT",
26
+ APP_INSTANCES_FORMAT: "APP_INSTANCES_FORMAT",
22
27
  };
23
28
 
24
29
  const ERROR_CODES_META = {
@@ -71,6 +76,21 @@ const ERROR_CODES_META = {
71
76
  [ERROR_CODES.APP_NAMES_FORMAT]: {
72
77
  message: "The app names property must be an array and only contain strings.",
73
78
  },
79
+ [ERROR_CODES.APP_INSTANCES_FORMAT]: {
80
+ message: "The app instances property must be an array and only contain numbers.",
81
+ },
82
+ [ERROR_CODES.CANT_PARSE_CRON]: {
83
+ message: "The cron expression is syntactically not correct and can't be parsed!",
84
+ },
85
+ [ERROR_CODES.CRON_INTERVAL]: {
86
+ message: "The difference between two cron execution must be greater than 10 seconds.",
87
+ },
88
+ [ERROR_CODES.NO_INTERVAL_OR_CRON]: {
89
+ message: "For periodic events either the cron or interval parameter must be defined!",
90
+ },
91
+ [ERROR_CODES.INTERVAL_AND_CRON]: {
92
+ message: "For periodic events only the cron or interval parameter can be defined!",
93
+ },
74
94
  };
75
95
 
76
96
  class EventQueueError extends VError {
@@ -186,6 +206,50 @@ class EventQueueError extends VError {
186
206
  );
187
207
  }
188
208
 
209
+ static cantParseCronExpression(type, subType, expression) {
210
+ const { message } = ERROR_CODES_META[ERROR_CODES.CANT_PARSE_CRON];
211
+ return new EventQueueError(
212
+ {
213
+ name: ERROR_CODES.CANT_PARSE_CRON,
214
+ info: { type, subType, expression },
215
+ },
216
+ message
217
+ );
218
+ }
219
+
220
+ static invalidIntervalBetweenCron(type, subType, interval) {
221
+ const { message } = ERROR_CODES_META[ERROR_CODES.CRON_INTERVAL];
222
+ return new EventQueueError(
223
+ {
224
+ name: ERROR_CODES.CRON_INTERVAL,
225
+ info: { type, subType, interval },
226
+ },
227
+ message
228
+ );
229
+ }
230
+
231
+ static noCronOrInterval(type, subType) {
232
+ const { message } = ERROR_CODES_META[ERROR_CODES.NO_INTERVAL_OR_CRON];
233
+ return new EventQueueError(
234
+ {
235
+ name: ERROR_CODES.CRON_INTERVAL,
236
+ info: { type, subType },
237
+ },
238
+ message
239
+ );
240
+ }
241
+
242
+ static cronAndInterval(type, subType) {
243
+ const { message } = ERROR_CODES_META[ERROR_CODES.INTERVAL_AND_CRON];
244
+ return new EventQueueError(
245
+ {
246
+ name: ERROR_CODES.INTERVAL_AND_CRON,
247
+ info: { type, subType },
248
+ },
249
+ message
250
+ );
251
+ }
252
+
189
253
  static missingImpl(type, subType) {
190
254
  const { message } = ERROR_CODES_META[ERROR_CODES.MISSING_IMPL];
191
255
  return new EventQueueError(
@@ -250,6 +314,17 @@ class EventQueueError extends VError {
250
314
  message
251
315
  );
252
316
  }
317
+
318
+ static appInstancesFormat(type, subType, appInstances) {
319
+ const { message } = ERROR_CODES_META[ERROR_CODES.APP_INSTANCES_FORMAT];
320
+ return new EventQueueError(
321
+ {
322
+ name: ERROR_CODES.APP_INSTANCES_FORMAT,
323
+ info: { type, subType, appInstances },
324
+ },
325
+ message
326
+ );
327
+ }
253
328
  }
254
329
 
255
330
  module.exports = EventQueueError;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const cds = require("@sap/cds");
4
+ const cronParser = require("cron-parser");
4
5
 
5
6
  const { executeInNewTransaction, TriggerRollback } = require("./shared/cdsHelper");
6
7
  const { EventProcessingStatus, TransactionMode } = require("./constants");
@@ -944,12 +945,27 @@ class EventQueueProcessorBase {
944
945
  }
945
946
  }
946
947
 
948
+ #calculateCronDates() {
949
+ if (!this.#eventConfig.cron) {
950
+ return null;
951
+ }
952
+
953
+ // NOTE: do not pass current date as we always want to calc. a future date
954
+ const cronExpression = cronParser.parseExpression(this.#eventConfig.cron, {
955
+ utc: this.#eventConfig.utc,
956
+ ...(this.#eventConfig.useCronTimezone && { tz: this.#config.cronTimezone }),
957
+ });
958
+ return cronExpression.next();
959
+ }
960
+
947
961
  async scheduleNextPeriodEvent(queueEntry) {
948
- const intervalInMs = this.#eventConfig.interval * 1000;
962
+ const intervalInMs = this.#eventConfig.cron ? null : this.#eventConfig.interval * 1000;
963
+ const next = this.#calculateCronDates();
964
+
949
965
  const newEvent = {
950
966
  type: this.#eventType,
951
967
  subType: this.#eventSubType,
952
- startAfter: new Date(new Date(queueEntry.startAfter).getTime() + intervalInMs),
968
+ startAfter: next ?? new Date(new Date(queueEntry.startAfter).getTime() + intervalInMs),
953
969
  };
954
970
  const { relative } = this.#eventSchedulerInstance.calculateOffset(
955
971
  this.#eventType,
@@ -958,6 +974,7 @@ class EventQueueProcessorBase {
958
974
  );
959
975
 
960
976
  // more than one interval behind - shift tick to keep up
977
+ // cron package always calc the next future date --> not needed for crone
961
978
  if (relative < 0 && Math.abs(relative) >= intervalInMs) {
962
979
  const plannedStartAfter = newEvent.startAfter;
963
980
  newEvent.startAfter = new Date(Date.now() + 5 * 1000);
@@ -970,7 +987,12 @@ class EventQueueProcessorBase {
970
987
  }
971
988
 
972
989
  this.tx._skipEventQueueBroadcase = true;
973
- await this.tx.run(INSERT.into(this.#config.tableNameEventQueue).entries({ ...newEvent }));
990
+ await this.tx.run(
991
+ INSERT.into(this.#config.tableNameEventQueue).entries({
992
+ ...newEvent,
993
+ startAfter: newEvent.startAfter.toISOString(),
994
+ })
995
+ );
974
996
  this.tx._skipEventQueueBroadcase = false;
975
997
  if (intervalInMs < this.#config.runInterval * 1.5) {
976
998
  this.#handleDelayedEvents([newEvent]);
@@ -979,7 +1001,7 @@ class EventQueueProcessorBase {
979
1001
  this.#eventSubType,
980
1002
  newEvent.startAfter
981
1003
  );
982
- // next tick is already behind schedule --> execute direct
1004
+ // NOTE: can only happen for interval events: next tick is already behind schedule --> execute direct
983
1005
  if (relativeAfterSchedule <= 0) {
984
1006
  this.logger.info("running behind schedule - executing next tick immediately", {
985
1007
  eventType: this.#eventType,
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const cds = require("@sap/cds");
4
+ const cronParser = require("cron-parser");
4
5
 
5
6
  const { getEnvInstance } = require("./shared/env");
6
7
  const redis = require("./shared/redis");
@@ -22,6 +23,9 @@ const COMMAND_UNBLOCK = "EVENT_QUEUE_EVENT_UNBLOCK";
22
23
  const CAP_EVENT_TYPE = "CAP_OUTBOX";
23
24
  const CAP_PARALLEL_DEFAULT = 5;
24
25
  const DELETE_TENANT_BLOCK_AFTER_MS = 5 * 60 * 1000;
26
+ const PRIORITIES = Object.values(Priorities);
27
+ const UTC_DEFAULT = false;
28
+ const USE_CRON_TZ_DEFAULT = true;
25
29
 
26
30
  const BASE_PERIODIC_EVENTS = [
27
31
  {
@@ -71,6 +75,8 @@ class Config {
71
75
  #enableCAPTelemetry;
72
76
  #unsubscribeHandlers = [];
73
77
  #unsubscribedTenants = {};
78
+ #cronTimezone;
79
+ #publishEventBlockList;
74
80
  static #instance;
75
81
  constructor() {
76
82
  this.#logger = cds.log(COMPONENT_NAME);
@@ -110,7 +116,27 @@ class Config {
110
116
 
111
117
  shouldBeProcessedInThisApplication(type, subType) {
112
118
  const config = this.#eventMap[this.generateKey(type, subType)];
113
- return !config._appNameMap || config._appNameMap[this.#env.applicationName];
119
+ const appNameConfig = config._appNameMap;
120
+ const appInstanceConfig = config._appInstancesMap;
121
+ if (!appNameConfig && !appInstanceConfig) {
122
+ return true;
123
+ }
124
+
125
+ if (appNameConfig) {
126
+ const shouldBeProcessedBasedOnAppName = appNameConfig[this.#env.applicationName];
127
+ if (!shouldBeProcessedBasedOnAppName) {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ if (appInstanceConfig) {
133
+ const shouldBeProcessedBasedOnAppInstance = appInstanceConfig[this.#env.applicationInstance];
134
+ if (!shouldBeProcessedBasedOnAppInstance) {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ return true;
114
140
  }
115
141
 
116
142
  checkRedisEnabled() {
@@ -215,7 +241,7 @@ class Config {
215
241
  }
216
242
  const key = this.generateKey(typeWithSuffix, subType);
217
243
  this.#blockEventLocalState(key, tenant);
218
- if (!this.redisEnabled) {
244
+ if (!this.redisEnabled || !this.publishEventBlockList) {
219
245
  return;
220
246
  }
221
247
 
@@ -282,12 +308,11 @@ class Config {
282
308
  checkForNextChunk: config.checkForNextChunk,
283
309
  deleteFinishedEventsAfterDays: config.deleteFinishedEventsAfterDays,
284
310
  appNames: config.appNames,
311
+ appInstances: config.appInstances,
285
312
  useEventQueueUser: config.useEventQueueUser,
286
313
  internalEvent: true,
287
314
  };
288
- eventConfig._appNameMap = eventConfig.appNames
289
- ? Object.fromEntries(new Map(eventConfig.appNames.map((a) => [a, true])))
290
- : null;
315
+ this.#basicEventTransformationAfterValidate(eventConfig);
291
316
  this.#config.events.push(eventConfig);
292
317
  this.#eventMap[this.generateKey(CAP_EVENT_TYPE, serviceName)] = eventConfig;
293
318
  }
@@ -324,55 +349,109 @@ class Config {
324
349
  config.events = config.events ?? [];
325
350
  config.periodicEvents = (config.periodicEvents ?? []).concat(BASE_PERIODIC_EVENTS.map((event) => ({ ...event })));
326
351
  this.#eventMap = config.events.reduce((result, event) => {
327
- event.load = event.load ?? DEFAULT_LOAD;
328
- event.priority = event.priority ?? DEFAULT_PRIORITY;
329
- this.validateAdHocEvents(result, event);
330
- event._appNameMap = event.appNames ? Object.fromEntries(new Map(event.appNames.map((a) => [a, true]))) : null;
352
+ this.#basicEventTransformation(event);
353
+ this.#validateAdHocEvents(result, event);
354
+ this.#basicEventTransformationAfterValidate(event);
331
355
  result[this.generateKey(event.type, event.subType)] = event;
332
356
  return result;
333
357
  }, {});
334
358
  this.#eventMap = config.periodicEvents.reduce((result, event) => {
335
- event.load = event.load ?? DEFAULT_LOAD;
336
359
  event.priority = event.priority ?? DEFAULT_PRIORITY;
337
360
  event.type = `${event.type}${SUFFIX_PERIODIC}`;
338
361
  event.isPeriodic = true;
339
- this.validatePeriodicConfig(result, event);
340
- event._appNameMap = event.appNames ? Object.fromEntries(new Map(event.appNames.map((a) => [a, true]))) : null;
362
+ this.#basicEventTransformation(event);
363
+ this.#validatePeriodicConfig(result, event);
364
+ this.#basicEventTransformationAfterValidate(event);
341
365
  result[this.generateKey(event.type, event.subType)] = event;
342
366
  return result;
343
367
  }, this.#eventMap);
344
368
  }
345
369
 
346
- validatePeriodicConfig(eventMap, config) {
347
- const key = this.generateKey(config.type, config.subType);
348
- if (eventMap[key] && eventMap[key].isPeriodic) {
349
- throw EventQueueError.duplicateEventRegistration(config.type, config.subType);
370
+ #basicEventTransformation(event) {
371
+ event.load = event.load ?? DEFAULT_LOAD;
372
+ event.priority = event.priority ?? DEFAULT_PRIORITY;
373
+ }
374
+
375
+ #basicEventTransformationAfterValidate(event) {
376
+ event._appNameMap = event.appNames ? Object.fromEntries(new Map(event.appNames.map((a) => [a, true]))) : null;
377
+ event._appInstancesMap = event.appInstances
378
+ ? Object.fromEntries(new Map(event.appInstances.map((a) => [a, true])))
379
+ : null;
380
+ }
381
+
382
+ #basicEventValidation(event) {
383
+ if (!event.impl) {
384
+ throw EventQueueError.missingImpl(event.type, event.subType);
350
385
  }
351
386
 
352
- if (!config.interval || config.interval <= MIN_INTERVAL_SEC) {
353
- throw EventQueueError.invalidInterval(config.type, config.subType, config.interval);
387
+ if (event.appNames) {
388
+ if (!Array.isArray(event.appNames) || event.appNames.some((appName) => typeof appName !== "string")) {
389
+ throw EventQueueError.appNamesFormat(event.type, event.subType, event.appNames);
390
+ }
354
391
  }
355
392
 
356
- if (!config.impl) {
357
- throw EventQueueError.missingImpl(config.type, config.subType);
393
+ if (event.appInstances) {
394
+ if (
395
+ !Array.isArray(event.appInstances) ||
396
+ event.appInstances.some((appInstance) => typeof appInstance !== "number")
397
+ ) {
398
+ throw EventQueueError.appInstancesFormat(event.type, event.subType, event.appInstances);
399
+ }
400
+ }
401
+
402
+ if (!PRIORITIES.includes(event.priority)) {
403
+ throw EventQueueError.priorityNotAllowed(event.priority, "initEvent");
404
+ }
405
+
406
+ if (event.load > this.#instanceLoadLimit) {
407
+ throw EventQueueError.loadHigherThanLimit(event.load, "initEvent");
358
408
  }
359
409
  }
360
410
 
361
- validateAdHocEvents(eventMap, config) {
362
- const key = this.generateKey(config.type, config.subType);
363
- if (eventMap[key] && !eventMap[key].isPeriodic) {
364
- throw EventQueueError.duplicateEventRegistration(config.type, config.subType);
411
+ #validatePeriodicConfig(eventMap, event) {
412
+ const key = this.generateKey(event.type, event.subType);
413
+ if (eventMap[key] && eventMap[key].isPeriodic) {
414
+ throw EventQueueError.duplicateEventRegistration(event.type, event.subType);
365
415
  }
366
416
 
367
- if (!config.impl) {
368
- throw EventQueueError.missingImpl(config.type, config.subType);
417
+ if (!event.cron && !event.interval) {
418
+ throw EventQueueError.noCronOrInterval(event.type, event.subType);
369
419
  }
370
420
 
371
- if (config.appNames) {
372
- if (!Array.isArray(config.appNames) || config.appNames.some((appName) => typeof appName !== "string")) {
373
- throw EventQueueError.appNamesFormat(config.type, config.subType, config.appNames);
421
+ if (event.cron && event.interval) {
422
+ throw EventQueueError.cronAndInterval(event.type, event.subType);
423
+ }
424
+
425
+ if (event.cron) {
426
+ let cron;
427
+ event.utc = event.utc ?? UTC_DEFAULT;
428
+ event.useCronTimezone = event.useCronTimezone ?? USE_CRON_TZ_DEFAULT;
429
+ try {
430
+ cron = cronParser.parseExpression(event.cron);
431
+ } catch {
432
+ throw EventQueueError.cantParseCronExpression(event.type, event.subType, event.cron);
374
433
  }
434
+ const next = cron.next();
435
+ const afterNext = cron.next();
436
+ const diffInSeconds = (afterNext.getTime() - next.getTime()) / 1000;
437
+ if (diffInSeconds <= MIN_INTERVAL_SEC) {
438
+ throw EventQueueError.invalidIntervalBetweenCron(event.type, event.subType, diffInSeconds);
439
+ }
440
+ return this.#basicEventValidation(event);
441
+ }
442
+
443
+ if (!event.interval || event.interval <= MIN_INTERVAL_SEC) {
444
+ throw EventQueueError.invalidInterval(event.type, event.subType, event.interval);
375
445
  }
446
+ this.#basicEventValidation(event);
447
+ }
448
+
449
+ #validateAdHocEvents(eventMap, event) {
450
+ const key = this.generateKey(event.type, event.subType);
451
+ if (eventMap[key] && !eventMap[key].isPeriodic) {
452
+ throw EventQueueError.duplicateEventRegistration(event.type, event.subType);
453
+ }
454
+ this.#basicEventValidation(event);
376
455
  }
377
456
 
378
457
  generateKey(type, subType) {
@@ -423,6 +502,14 @@ class Config {
423
502
  this.#forUpdateTimeout = value;
424
503
  }
425
504
 
505
+ get publishEventBlockList() {
506
+ return this.#publishEventBlockList;
507
+ }
508
+
509
+ set publishEventBlockList(value) {
510
+ this.#publishEventBlockList = value;
511
+ }
512
+
426
513
  set globalTxTimeout(value) {
427
514
  this.#globalTxTimeout = value;
428
515
  }
@@ -454,6 +541,14 @@ class Config {
454
541
  this.#initialized = value;
455
542
  }
456
543
 
544
+ get cronTimezone() {
545
+ return this.#cronTimezone;
546
+ }
547
+
548
+ set cronTimezone(value) {
549
+ this.#cronTimezone = value;
550
+ }
551
+
457
552
  get instanceLoadLimit() {
458
553
  return this.#instanceLoadLimit;
459
554
  }
package/src/index.d.ts CHANGED
@@ -106,6 +106,11 @@ interface EventEntityPublish {
106
106
  payload?: string;
107
107
  }
108
108
 
109
+ interface EventTriggerProcessing {
110
+ type: string;
111
+ subType: string;
112
+ }
113
+
109
114
  interface QueueEntriesPayloadMap {
110
115
  [key: string]: {
111
116
  queueEntry: EventEntity;
@@ -163,6 +168,12 @@ export function processEventQueue(
163
168
  startTime: Date
164
169
  ): Promise<any>;
165
170
 
171
+ export function triggerEventProcessingRedis(
172
+ tenantId: string,
173
+ events: EventTriggerProcessing[],
174
+ forceBroadcast?: boolean
175
+ ): Promise<any>;
176
+
166
177
  declare class Config {
167
178
  constructor();
168
179
 
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- // TODO: add tests for config --> similar to csn check
3
+ const redisPubSub = require("./redis/redisPub");
4
4
 
5
5
  module.exports = {
6
6
  ...require("./initialize"),
@@ -11,4 +11,5 @@ module.exports = {
11
11
  ...require("./publishEvent"),
12
12
  EventQueueProcessorBase: require("./EventQueueProcessorBase"),
13
13
  WorkerQueue: require("./shared/WorkerQueue"),
14
+ triggerEventProcessingRedis: redisPubSub.broadcastEvent,
14
15
  };
package/src/initialize.js CHANGED
@@ -35,47 +35,40 @@ const CONFIG_VARS = [
35
35
  ["userId", null],
36
36
  ["cleanupLocksAndEventsForDev", false],
37
37
  ["redisOptions", {}],
38
- ["insertEventsBeforeCommit", false],
38
+ ["insertEventsBeforeCommit", true],
39
39
  ["enableCAPTelemetry", false],
40
+ ["defaultTimezoneForCron", null],
41
+ ["publishEventBlockList", true],
40
42
  ];
41
43
 
42
- const initialize = async ({
43
- configFilePath,
44
- registerAsEventProcessor,
45
- processEventsAfterPublish,
46
- isEventQueueActive,
47
- runInterval,
48
- disableRedis,
49
- updatePeriodicEvents,
50
- thresholdLoggingEventProcessing,
51
- useAsCAPOutbox,
52
- userId,
53
- cleanupLocksAndEventsForDev,
54
- redisOptions,
55
- insertEventsBeforeCommit,
56
- enableCAPTelemetry,
57
- } = {}) => {
44
+ /**
45
+ * Initializes the event queue with the provided options.
46
+ *
47
+ * @param {Object} options - The configuration options.
48
+ * @param {string} [options.configFilePath=null] - Path to the configuration file.
49
+ * @param {boolean} [options.registerAsEventProcessor=true] - Register the instance as an event processor.
50
+ * @param {boolean} [options.processEventsAfterPublish=true] - Process events immediately after publishing.
51
+ * @param {boolean} [options.isEventQueueActive=true] - Flag to activate/deactivate the event queue.
52
+ * @param {number} [options.runInterval=1500000] - Interval for running event queue processing (in milliseconds).
53
+ * @param {boolean} [options.disableRedis=true] - Disable Redis usage for event handling.
54
+ * @param {boolean} [options.updatePeriodicEvents=true] - Automatically update periodic events.
55
+ * @param {number} [options.thresholdLoggingEventProcessing=50] - Threshold for logging event processing time (in milliseconds).
56
+ * @param {boolean} [options.useAsCAPOutbox=false] - Use the event queue as a CAP Outbox.
57
+ * @param {string} [options.userId=null] - ID of the user initiating the process.
58
+ * @param {boolean} [options.cleanupLocksAndEventsForDev=false] - Cleanup locks and events for development environments.
59
+ * @param {Object} [options.redisOptions={}] - Configuration options for Redis.
60
+ * @param {boolean} [options.insertEventsBeforeCommit=true] - Insert events into the queue before committing the transaction.
61
+ * @param {boolean} [options.enableCAPTelemetry=false] - Enable telemetry for CAP.
62
+ * @param {string} [options.defaultTimezoneForCron=null] - Default timezone for cron jobs.
63
+ * @param {string} [options.publishEventBlockList=true] - If redis is available event blocklist is distributed to all application instances
64
+ */
65
+ const initialize = async (options = {}) => {
58
66
  if (config.initialized) {
59
67
  return;
60
68
  }
61
69
  config.initialized = true;
62
70
 
63
- mixConfigVarsWithEnv(
64
- configFilePath,
65
- registerAsEventProcessor,
66
- processEventsAfterPublish,
67
- isEventQueueActive,
68
- runInterval,
69
- disableRedis,
70
- updatePeriodicEvents,
71
- thresholdLoggingEventProcessing,
72
- useAsCAPOutbox,
73
- userId,
74
- cleanupLocksAndEventsForDev,
75
- redisOptions,
76
- insertEventsBeforeCommit,
77
- enableCAPTelemetry
78
- );
71
+ mixConfigVarsWithEnv(options);
79
72
 
80
73
  const logger = cds.log(COMPONENT);
81
74
  const redisEnabled = config.checkRedisEnabled();
@@ -167,9 +160,9 @@ const monkeyPatchCAPOutbox = () => {
167
160
  }
168
161
  };
169
162
 
170
- const mixConfigVarsWithEnv = (...args) => {
171
- CONFIG_VARS.forEach(([configName, defaultValue], index) => {
172
- const configValue = args[index];
163
+ const mixConfigVarsWithEnv = (options) => {
164
+ CONFIG_VARS.forEach(([configName, defaultValue]) => {
165
+ const configValue = options[configName];
173
166
  config[configName] = configValue ?? cds.env.eventQueue?.[configName] ?? defaultValue;
174
167
  });
175
168
  };
@@ -7,10 +7,9 @@ const config = require("../config");
7
7
 
8
8
  const OUTBOXED = Symbol("outboxed");
9
9
  const UNBOXED = Symbol("unboxed");
10
-
11
10
  const CDS_EVENT_TYPE = "CAP_OUTBOX";
12
-
13
11
  const COMPONENT_NAME = "/eventQueue/eventQueueAsOutbox";
12
+ const EVENT_QUEUE_SPECIFIC_FIELDS = ["startAfter", "referenceEntity", "referenceEntityKey"];
14
13
 
15
14
  function outboxed(srv, customOpts) {
16
15
  // outbox max. once
@@ -74,12 +73,14 @@ function unboxed(srv) {
74
73
  }
75
74
 
76
75
  const _mapToEventAndPublish = async (context, name, req) => {
77
- let startAfter;
76
+ const eventQueueSpecificValues = {};
78
77
  for (const header in req.headers ?? {}) {
79
- if (header.toLocaleLowerCase() === "x-eventqueue-startafter") {
80
- startAfter = req.headers[header];
81
- delete req.headers[header];
82
- break;
78
+ for (const field of EVENT_QUEUE_SPECIFIC_FIELDS) {
79
+ if (header.toLocaleLowerCase() === `x-eventqueue-${field.toLocaleLowerCase()}`) {
80
+ eventQueueSpecificValues[field] = req.headers[header];
81
+ delete req.headers[header];
82
+ break;
83
+ }
83
84
  }
84
85
  }
85
86
  const event = {
@@ -96,7 +97,7 @@ const _mapToEventAndPublish = async (context, name, req) => {
96
97
  type: CDS_EVENT_TYPE,
97
98
  subType: name,
98
99
  payload: JSON.stringify(event),
99
- ...(startAfter && { startAfter }),
100
+ ...eventQueueSpecificValues,
100
101
  });
101
102
  };
102
103
 
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const cds = require("@sap/cds");
4
+ const cronParser = require("cron-parser");
4
5
 
5
6
  const { EventProcessingStatus } = require("./constants");
6
7
  const { processChunkedSync } = require("./shared/common");
@@ -10,6 +11,7 @@ const COMPONENT_NAME = "/eventQueue/periodicEvents";
10
11
  const CHUNK_SIZE_INSERT_PERIODIC_EVENTS = 4;
11
12
 
12
13
  const checkAndInsertPeriodicEvents = async (context) => {
14
+ const now = new Date();
13
15
  const tx = cds.tx(context);
14
16
  const baseCqn = SELECT.from(eventConfig.tableNameEventQueue)
15
17
  .where([
@@ -27,12 +29,15 @@ const checkAndInsertPeriodicEvents = async (context) => {
27
29
  list: [{ val: EventProcessingStatus.Open }, { val: EventProcessingStatus.InProgress }],
28
30
  },
29
31
  ])
30
- .columns(["ID", "type", "subType", "startAfter"]);
32
+ .groupBy("type", "subType", "createdAt")
33
+ .columns(["type", "subType", "createdAt", "max(startAfter) as startAfter"]);
31
34
  const currentPeriodEvents = await tx.run(baseCqn);
35
+ currentPeriodEvents.length &&
36
+ (await tx.run(_addWhere(SELECT.from(eventConfig.tableNameEventQueue).columns("ID"), currentPeriodEvents)));
32
37
 
33
38
  if (!currentPeriodEvents.length) {
34
39
  // fresh insert all
35
- return await insertPeriodEvents(tx, eventConfig.periodicEvents);
40
+ return await _insertPeriodEvents(tx, eventConfig.periodicEvents, now);
36
41
  }
37
42
 
38
43
  const exitingEventMap = currentPeriodEvents.reduce((result, current) => {
@@ -41,26 +46,27 @@ const checkAndInsertPeriodicEvents = async (context) => {
41
46
  return result;
42
47
  }, {});
43
48
 
44
- const { newEvents, existingEvents } = eventConfig.periodicEvents.reduce(
49
+ const { newEvents, existingEventsCron, existingEventsInterval } = eventConfig.periodicEvents.reduce(
45
50
  (result, event) => {
46
- if (exitingEventMap[_generateKey(event)]) {
47
- result.existingEvents.push(exitingEventMap[_generateKey(event)]);
51
+ const existingEvent = exitingEventMap[_generateKey(event)];
52
+ if (existingEvent) {
53
+ const config = eventConfig.getEventConfig(existingEvent.type, existingEvent.subType);
54
+ if (config.cron) {
55
+ result.existingEventsCron.push(exitingEventMap[_generateKey(event)]);
56
+ } else {
57
+ result.existingEventsInterval.push(exitingEventMap[_generateKey(event)]);
58
+ }
48
59
  } else {
49
60
  result.newEvents.push(event);
50
61
  }
51
62
  return result;
52
63
  },
53
- { newEvents: [], existingEvents: [] }
64
+ { newEvents: [], existingEventsCron: [], existingEventsInterval: [] }
54
65
  );
55
66
 
56
- const currentDate = new Date();
57
- const exitingWithNotMatchingInterval = existingEvents.filter((existingEvent) => {
58
- const config = eventConfig.getEventConfig(existingEvent.type, existingEvent.subType);
59
- const eventStartAfter = new Date(existingEvent.startAfter);
60
- // check if to far in future
61
- const dueInWithNewInterval = new Date(currentDate.getTime() + config.interval * 1000);
62
- return eventStartAfter >= dueInWithNewInterval;
63
- });
67
+ const exitingWithNotMatchingInterval = []
68
+ .concat(_determineChangedInterval(existingEventsInterval, now))
69
+ .concat(_determineChangedCron(existingEventsCron, now));
64
70
 
65
71
  exitingWithNotMatchingInterval.length &&
66
72
  cds.log(COMPONENT_NAME).info("deleting periodic events because they have changed", {
@@ -68,12 +74,14 @@ const checkAndInsertPeriodicEvents = async (context) => {
68
74
  });
69
75
 
70
76
  if (exitingWithNotMatchingInterval.length) {
71
- await tx.run(
72
- DELETE.from(eventConfig.tableNameEventQueue).where(
73
- "ID IN",
74
- exitingWithNotMatchingInterval.map(({ ID }) => ID)
75
- )
76
- );
77
+ const cqnBase = DELETE.from(eventConfig.tableNameEventQueue);
78
+ _addWhere(cqnBase, exitingWithNotMatchingInterval);
79
+ const deleteCount = await tx.run(cqnBase);
80
+ if (deleteCount !== exitingWithNotMatchingInterval.length) {
81
+ cds.log(COMPONENT_NAME).warn("deletion count doesn't match expected count", {
82
+ deleteCount,
83
+ });
84
+ }
77
85
  }
78
86
 
79
87
  const newOrChangedEvents = newEvents.concat(exitingWithNotMatchingInterval);
@@ -82,31 +90,74 @@ const checkAndInsertPeriodicEvents = async (context) => {
82
90
  return;
83
91
  }
84
92
 
85
- return await insertPeriodEvents(tx, newOrChangedEvents);
93
+ return await _insertPeriodEvents(tx, newOrChangedEvents, now);
94
+ };
95
+
96
+ const _addWhere = (cqnBase, events) => {
97
+ let or = false;
98
+ for (const { type, subType, createdAt, startAfter } of events) {
99
+ cqnBase[or ? "or" : "where"]({ type, subType, createdAt, startAfter });
100
+ or = true;
101
+ }
102
+ return cqnBase;
103
+ };
104
+
105
+ const _determineChangedInterval = (existingEvents, currentDate) => {
106
+ return existingEvents.filter((existingEvent) => {
107
+ const config = eventConfig.getEventConfig(existingEvent.type, existingEvent.subType);
108
+ const eventStartAfter = new Date(existingEvent.startAfter);
109
+ // check if too far in future
110
+ const dueInWithNewInterval = new Date(currentDate.getTime() + config.interval * 1000);
111
+ return eventStartAfter >= dueInWithNewInterval;
112
+ });
113
+ };
114
+
115
+ const _determineChangedCron = (existingEventsCron) => {
116
+ return existingEventsCron.filter((event) => {
117
+ const config = eventConfig.getEventConfig(event.type, event.subType);
118
+ const eventStartAfter = new Date(event.startAfter);
119
+ const eventCreatedAt = new Date(event.createdAt);
120
+ const cronExpression = cronParser.parseExpression(config.cron, {
121
+ currentDate: eventCreatedAt,
122
+ utc: config.utc,
123
+ ...(config.useCronTimezone && { tz: eventConfig.cronTimezone }),
124
+ });
125
+ return cronExpression.next().getTime() - eventStartAfter.getTime() > 30 * 1000; // report as changed if diff created than 30 seconds
126
+ });
86
127
  };
87
128
 
88
- const insertPeriodEvents = async (tx, events) => {
89
- const startAfter = new Date();
129
+ const _insertPeriodEvents = async (tx, events, now) => {
90
130
  let counter = 1;
91
131
  const chunks = Math.ceil(events.length / CHUNK_SIZE_INSERT_PERIODIC_EVENTS);
92
132
  const logger = cds.log(COMPONENT_NAME);
93
- processChunkedSync(events, CHUNK_SIZE_INSERT_PERIODIC_EVENTS, (chunk) => {
133
+ const eventsToBeInserted = events.map((event) => {
134
+ const base = { type: event.type, subType: event.subType };
135
+ let startTime = now;
136
+ if (event.cron) {
137
+ startTime = cronParser
138
+ .parseExpression(event.cron, {
139
+ currentDate: now,
140
+ utc: event.utc,
141
+ ...(event.useCronTimezone && { tz: eventConfig.cronTimezone }),
142
+ })
143
+ .next();
144
+ }
145
+ base.startAfter = startTime.toISOString();
146
+ return base;
147
+ }, []);
148
+
149
+ processChunkedSync(eventsToBeInserted, CHUNK_SIZE_INSERT_PERIODIC_EVENTS, (chunk) => {
94
150
  logger.info(`${counter}/${chunks} | inserting chunk of changed or new periodic events`, {
95
- events: chunk.map(({ type, subType }) => {
151
+ events: chunk.map(({ type, subType, startAfter }) => {
96
152
  const { interval } = eventConfig.getEventConfig(type, subType);
97
- return { type, subType, interval };
153
+ return { type, subType, interval, ...(startAfter && { startAfter }) };
98
154
  }),
99
155
  });
100
156
  counter++;
101
157
  });
102
- const periodEventsInsert = events.map((periodicEvent) => ({
103
- type: periodicEvent.type,
104
- subType: periodicEvent.subType,
105
- startAfter: startAfter,
106
- }));
107
158
 
108
159
  tx._skipEventQueueBroadcase = true;
109
- await tx.run(INSERT.into(eventConfig.tableNameEventQueue).entries(periodEventsInsert));
160
+ await tx.run(INSERT.into(eventConfig.tableNameEventQueue).entries(eventsToBeInserted));
110
161
  tx._skipEventQueueBroadcase = false;
111
162
  };
112
163
 
@@ -18,7 +18,37 @@ const SLEEP_TIME_FOR_PUBLISH_PERIODIC_EVENT = 30 * 1000;
18
18
 
19
19
  const wait = promisify(setTimeout);
20
20
 
21
- const broadcastEvent = async (tenantId, events) => {
21
+ /**
22
+ * Broadcasts events to the event queue, either locally or through Redis.
23
+ *
24
+ * This function checks if the event queue is active before proceeding to broadcast the events.
25
+ * If the event queue is deactivated, broadcasting is skipped. If Redis is not enabled,
26
+ * events will be processed locally without Redis. The function handles periodic events
27
+ * by checking for locks and only publishing when locks are available.
28
+ *
29
+ * @async
30
+ * @param {string} tenantId - The ID of the tenant for which the events are being broadcasted.
31
+ * @param {Array<{ type: string; subType: string }>} events - An array of event objects, each containing
32
+ * a type and a subtype that specify the kind of event to be broadcasted.
33
+ * @param {boolean} [forceBroadcast=false] - If true, forces the broadcast of periodic events even
34
+ * when locks are not available. Defaults to false.
35
+ * @returns {Promise<void>} A promise that resolves when the events have been successfully broadcasted.
36
+ *
37
+ * @throws {Error} Throws an error if publishing events fails.
38
+ *
39
+ * @example
40
+ * // Example usage of broadcastEvent function
41
+ * const tenantId = '12345';
42
+ * const events = [
43
+ * { type: 'orderCreated', subType: 'online' },
44
+ * { type: 'paymentProcessed', subType: 'creditCard' }
45
+ * ];
46
+ *
47
+ * broadcastEvent(tenantId, events)
48
+ * .then(() => console.log('Events broadcasted successfully!'))
49
+ * .catch(err => console.error('Failed to broadcast events:', err));
50
+ */
51
+ const broadcastEvent = async (tenantId, events, forceBroadcast = false) => {
22
52
  const logger = cds.log(COMPONENT_NAME);
23
53
 
24
54
  if (!config.isEventQueueActive) {
@@ -46,7 +76,7 @@ const broadcastEvent = async (tenantId, events) => {
46
76
  isPeriodic: eventConfig.isPeriodic,
47
77
  waitInterval: SLEEP_TIME_FOR_PUBLISH_PERIODIC_EVENT,
48
78
  });
49
- if (!eventConfig.isPeriodic) {
79
+ if (!eventConfig.isPeriodic && !forceBroadcast) {
50
80
  break;
51
81
  }
52
82
  await wait(SLEEP_TIME_FOR_PUBLISH_PERIODIC_EVENT);
@@ -5,7 +5,7 @@ const cds = require("@sap/cds");
5
5
  const eventConfig = require("../config");
6
6
  const { EventProcessingStatus } = require("../constants");
7
7
 
8
- const getOpenQueueEntries = async (tx) => {
8
+ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
9
9
  const startTime = new Date();
10
10
  const refDateStartAfter = new Date(startTime.getTime() + eventConfig.runInterval * 1.2);
11
11
  const entries = await tx.run(
@@ -37,14 +37,25 @@ const getOpenQueueEntries = async (tx) => {
37
37
  return;
38
38
  }
39
39
  cds.outboxed(service);
40
- if (eventConfig.shouldBeProcessedInThisApplication(type, subType)) {
40
+ if (filterAppSpecificEvents) {
41
+ if (eventConfig.shouldBeProcessedInThisApplication(type, subType)) {
42
+ result.push({ type, subType });
43
+ }
44
+ } else {
41
45
  result.push({ type, subType });
42
46
  }
43
47
  })
44
48
  .catch(() => {});
45
49
  } else {
46
- if (eventConfig.getEventConfig(type, subType) && eventConfig.shouldBeProcessedInThisApplication(type, subType)) {
47
- result.push({ type, subType });
50
+ if (filterAppSpecificEvents) {
51
+ if (
52
+ eventConfig.getEventConfig(type, subType) &&
53
+ eventConfig.shouldBeProcessedInThisApplication(type, subType)
54
+ ) {
55
+ result.push({ type, subType });
56
+ }
57
+ } else {
58
+ eventConfig.getEventConfig(type, subType) && result.push({ type, subType });
48
59
  }
49
60
  }
50
61
  }
@@ -134,7 +134,7 @@ const _executeEventsAllTenantsRedis = async (tenantIds) => {
134
134
  id: config.userId,
135
135
  authInfo: await common.getAuthInfo(tenantId),
136
136
  });
137
- const entries = await openEvents.getOpenQueueEntries(tx);
137
+ const entries = await openEvents.getOpenQueueEntries(tx, false);
138
138
  logger.info("broadcasting events for run", {
139
139
  tenantId,
140
140
  entries: entries.length,
package/src/shared/env.js CHANGED
@@ -5,6 +5,7 @@ let instance;
5
5
  class Env {
6
6
  #vcapServices;
7
7
  #vcapApplication;
8
+ #vcapApplicationInstance;
8
9
 
9
10
  constructor() {
10
11
  try {
@@ -14,6 +15,7 @@ class Env {
14
15
  this.#vcapServices = {};
15
16
  this.#vcapApplication = {};
16
17
  }
18
+ this.#vcapApplicationInstance = Number(process.env.CF_INSTANCE_INDEX);
17
19
  }
18
20
 
19
21
  get redisCredentialsFromEnv() {
@@ -24,6 +26,10 @@ class Env {
24
26
  return this.#vcapApplication.application_name;
25
27
  }
26
28
 
29
+ get applicationInstance() {
30
+ return this.#vcapApplicationInstance;
31
+ }
32
+
27
33
  set vcapServices(value) {
28
34
  this.#vcapServices = value;
29
35
  }
@@ -32,6 +38,10 @@ class Env {
32
38
  return this.#vcapServices;
33
39
  }
34
40
 
41
+ set applicationInstance(value) {
42
+ this.#vcapApplicationInstance = value;
43
+ }
44
+
35
45
  set vcapApplication(value) {
36
46
  this.#vcapApplication = value;
37
47
  }