@cap-js-community/event-queue 0.3.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "0.3.0",
3
+ "version": "1.0.1",
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": [
@@ -22,7 +22,7 @@
22
22
  "scripts": {
23
23
  "test:unit": "jest --testPathIgnorePatterns=\"/test-integration/\"",
24
24
  "test:integration": "jest --testPathIgnorePatterns=\"/test/\" --runInBand --forceExit",
25
- "test:all": "npm run test:unit && npm run test:integration",
25
+ "test": "npm run test:unit && npm run test:integration",
26
26
  "test:all:coverage": "jest --runInBand --forceExit --collect-coverage",
27
27
  "test:prepare": "npm run build:ci --prefix=./test-integration/_env",
28
28
  "test:deploySchema": "node test-integration/_env/srv/hana/deploy.js",
@@ -39,25 +39,25 @@
39
39
  "upgrade-lock": "npx shx rm -rf package-lock.json node_modules && npm i --package-lock"
40
40
  },
41
41
  "engines": {
42
- "node": ">=16"
42
+ "node": ">=18"
43
43
  },
44
44
  "dependencies": {
45
- "redis": "4.6.11",
45
+ "redis": "4.6.12",
46
46
  "verror": "1.10.1",
47
47
  "yaml": "2.3.4"
48
48
  },
49
49
  "devDependencies": {
50
- "@sap/cds": "7.4.0",
51
- "@sap/cds-dk": "7.4.0",
52
- "eslint": "8.54.0",
53
- "eslint-config-prettier": "9.0.0",
54
- "eslint-plugin-jest": "27.6.0",
50
+ "@sap/cds": "7.5.1",
51
+ "@sap/cds-dk": "7.5.0",
52
+ "eslint": "8.56.0",
53
+ "eslint-config-prettier": "9.1.0",
54
+ "eslint-plugin-jest": "27.6.1",
55
55
  "eslint-plugin-node": "11.1.0",
56
56
  "express": "4.18.2",
57
- "hdb": "0.19.6",
57
+ "hdb": "0.19.7",
58
58
  "jest": "29.7.0",
59
59
  "prettier": "2.8.8",
60
- "sqlite3": "5.1.6"
60
+ "sqlite3": "5.1.7-rc.0"
61
61
  },
62
62
  "homepage": "https://cap-js-community.github.io/event-queue/",
63
63
  "repository": {
@@ -210,12 +210,12 @@ class EventQueueError extends VError {
210
210
  message
211
211
  );
212
212
  }
213
- static loadHigherThanLimit(load) {
213
+ static loadHigherThanLimit(load, label) {
214
214
  const { message } = ERROR_CODES_META[ERROR_CODES.LOAD_HIGHER_THAN_LIMIT];
215
215
  return new EventQueueError(
216
216
  {
217
217
  name: ERROR_CODES.LOAD_HIGHER_THAN_LIMIT,
218
- info: { load },
218
+ info: { load, label },
219
219
  },
220
220
  message
221
221
  );
@@ -18,8 +18,6 @@ const DEFAULT_RETRY_ATTEMPTS = 3;
18
18
  const DEFAULT_PARALLEL_EVENT_PROCESSING = 1;
19
19
  const LIMIT_PARALLEL_EVENT_PROCESSING = 10;
20
20
  const SELECT_LIMIT_EVENTS_PER_TICK = 100;
21
- const DEFAULT_DELETE_FINISHED_EVENTS_AFTER = 0;
22
- const DAYS_TO_MS = 24 * 60 * 60 * 1000;
23
21
  const TRIES_FOR_EXCEEDED_EVENTS = 3;
24
22
  const EVENT_START_AFTER_HEADROOM = 3 * 1000;
25
23
 
@@ -34,6 +32,7 @@ class EventQueueProcessorBase {
34
32
  #eventSchedulerInstance = null;
35
33
  #eventConfig;
36
34
  #isPeriodic;
35
+ #lastSuccessfulRunTimestamp;
37
36
 
38
37
  constructor(context, eventType, eventSubType, config) {
39
38
  this.__context = context;
@@ -57,21 +56,12 @@ class EventQueueProcessorBase {
57
56
  // NOTE: keep the feature, this might be needed again
58
57
  this.__concurrentEventProcessing = false;
59
58
  this.__startTime = this.#eventConfig.startTime ?? new Date();
60
- this.__retryAttempts = this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS;
59
+ this.__retryAttempts = this.#isPeriodic ? 1 : this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS;
61
60
  this.__selectMaxChunkSize = this.#eventConfig.selectMaxChunkSize ?? SELECT_LIMIT_EVENTS_PER_TICK;
62
61
  this.__selectNextChunk = !!this.#eventConfig.checkForNextChunk;
63
62
  this.__keepalivePromises = {};
64
63
  this.__outdatedCheckEnabled = this.#eventConfig.eventOutdatedCheck ?? true;
65
64
  this.__transactionMode = this.#eventConfig.transactionMode ?? TransactionMode.isolated;
66
- if (this.#eventConfig.deleteFinishedEventsAfterDays) {
67
- this.__deleteFinishedEventsAfter =
68
- Number.isInteger(this.#eventConfig.deleteFinishedEventsAfterDays) &&
69
- this.#eventConfig.deleteFinishedEventsAfterDays > 0
70
- ? this.#eventConfig.deleteFinishedEventsAfterDays
71
- : DEFAULT_DELETE_FINISHED_EVENTS_AFTER;
72
- } else {
73
- this.__deleteFinishedEventsAfter = DEFAULT_DELETE_FINISHED_EVENTS_AFTER;
74
- }
75
65
  this.__emptyChunkSelected = false;
76
66
  this.__lockAcquired = false;
77
67
  this.__txUsageAllowed = true;
@@ -113,16 +103,45 @@ class EventQueueProcessorBase {
113
103
  }
114
104
 
115
105
  startPerformanceTracerEvents() {
116
- this.__performanceLoggerEvents = new PerformanceTracer(this.logger, "Processing events");
106
+ this.__performanceLoggerEvents = new PerformanceTracer(this.logger, "Processing events", {
107
+ properties: {
108
+ type: this.eventType,
109
+ subType: this.eventSubType,
110
+ },
111
+ });
112
+ }
113
+
114
+ startPerformanceTracerPeriodicEvents() {
115
+ this.__performanceLoggerPeriodicEvents = new PerformanceTracer(this.logger, "Processing periodic event", {
116
+ properties: {
117
+ type: this.eventType,
118
+ subType: this.eventSubType,
119
+ },
120
+ });
117
121
  }
118
122
 
119
123
  startPerformanceTracerPreprocessing() {
120
- this.__performanceLoggerPreprocessing = new PerformanceTracer(this.logger, "Preprocessing events");
124
+ this.__performanceLoggerPreprocessing = new PerformanceTracer(this.logger, "Preprocessing events", {
125
+ properties: {
126
+ type: this.eventType,
127
+ subType: this.eventSubType,
128
+ },
129
+ });
121
130
  }
122
131
 
123
132
  endPerformanceTracerEvents() {
124
133
  this.__performanceLoggerEvents?.endPerformanceTrace(
125
- { threshold: 50 },
134
+ { threshold: this.#config.thresholdLoggingEventProcessing },
135
+ {
136
+ eventType: this.#eventType,
137
+ eventSubType: this.#eventSubType,
138
+ }
139
+ );
140
+ }
141
+
142
+ endPerformanceTracerPeriodicEvents() {
143
+ this.__performanceLoggerPeriodicEvents?.endPerformanceTrace(
144
+ { threshold: this.#config.thresholdLoggingEventProcessing },
126
145
  {
127
146
  eventType: this.#eventType,
128
147
  eventSubType: this.#eventSubType,
@@ -148,10 +167,9 @@ class EventQueueProcessorBase {
148
167
  });
149
168
  }
150
169
 
151
- logStartMessage(queueEntries) {
152
- // TODO: how to handle custom fields
170
+ logStartMessage() {
153
171
  this.logger.info("Processing queue event", {
154
- numberQueueEntries: queueEntries.length,
172
+ numberClusterEntries: Object.keys(this.eventProcessingMap).length,
155
173
  eventType: this.#eventType,
156
174
  eventSubType: this.#eventSubType,
157
175
  });
@@ -332,11 +350,11 @@ class EventQueueProcessorBase {
332
350
  });
333
351
  }
334
352
 
335
- async setPeriodicEventStatus(queueEntryIds) {
353
+ async setPeriodicEventStatus(queueEntryIds, status) {
336
354
  await this.tx.run(
337
355
  UPDATE.entity(this.#config.tableNameEventQueue)
338
356
  .set({
339
- status: EventProcessingStatus.Done,
357
+ status: status,
340
358
  })
341
359
  .where({
342
360
  ID: queueEntryIds,
@@ -426,28 +444,6 @@ class EventQueueProcessorBase {
426
444
  });
427
445
  }
428
446
 
429
- async deleteFinishedEvents(tx) {
430
- if (!this.__deleteFinishedEventsAfter) {
431
- return;
432
- }
433
- const deleteCount = await tx.run(
434
- DELETE.from(this.#config.tableNameEventQueue).where(
435
- "type =",
436
- this.#eventType,
437
- "AND subType=",
438
- this.#eventSubType,
439
- "AND lastAttemptTimestamp <=",
440
- new Date(Date.now() - this.__deleteFinishedEventsAfter * DAYS_TO_MS).toISOString()
441
- )
442
- );
443
- this.logger.debug("Deleted finished events", {
444
- eventType: this.#eventType,
445
- eventSubType: this.#eventSubType,
446
- deleteFinishedEventsAfter: this.__deleteFinishedEventsAfter,
447
- deleteCount,
448
- });
449
- }
450
-
451
447
  #ensureEveryQueueEntryHasStatus() {
452
448
  this.__queueEntries.forEach((queueEntry) => {
453
449
  if (queueEntry.ID in this.__statusMap || queueEntry.ID in this.__commitedStatusMap) {
@@ -549,15 +545,27 @@ class EventQueueProcessorBase {
549
545
  refDateStartAfter.toISOString(),
550
546
  " ) AND ( status =",
551
547
  EventProcessingStatus.Open,
552
- "OR ( status =",
553
- EventProcessingStatus.Error,
554
- "AND lastAttemptTimestamp <=",
548
+ "AND ( lastAttemptTimestamp <=",
555
549
  this.__startTime.toISOString(),
556
- ") OR ( status =",
557
- EventProcessingStatus.InProgress,
558
- "AND lastAttemptTimestamp <=",
559
- new Date(new Date().getTime() - this.#config.globalTxTimeout).toISOString(),
560
- ") )"
550
+ ...(this.isPeriodicEvent
551
+ ? [
552
+ "OR lastAttemptTimestamp IS NULL ) OR ( status =",
553
+ EventProcessingStatus.InProgress,
554
+ "AND lastAttemptTimestamp <=",
555
+ new Date(new Date().getTime() - this.#config.globalTxTimeout).toISOString(),
556
+ ") )",
557
+ ]
558
+ : [
559
+ "OR lastAttemptTimestamp IS NULL ) OR ( status =",
560
+ EventProcessingStatus.Error,
561
+ "AND lastAttemptTimestamp <=",
562
+ this.__startTime.toISOString(),
563
+ ") OR ( status =",
564
+ EventProcessingStatus.InProgress,
565
+ "AND lastAttemptTimestamp <=",
566
+ new Date(new Date().getTime() - this.#config.globalTxTimeout).toISOString(),
567
+ ") )",
568
+ ])
561
569
  )
562
570
  .orderBy("createdAt", "ID")
563
571
  );
@@ -575,9 +583,11 @@ class EventQueueProcessorBase {
575
583
  entries,
576
584
  refDateStartAfter
577
585
  );
578
- const eventsForProcessing = exceededTries.concat(openEvents).concat(exceededTriesExceeded);
586
+ const eventsForProcessing = openEvents
587
+ .concat(exceededTriesExceeded)
588
+ .concat(this.#isPeriodic ? [] : exceededTries);
579
589
  this.#selectedEventMap = arrayToFlatMap(eventsForProcessing);
580
- if (exceededTries.length) {
590
+ if (!this.#isPeriodic && exceededTries.length) {
581
591
  this.#eventsWithExceededTries = exceededTries;
582
592
  }
583
593
  if (exceededTriesExceeded.length) {
@@ -586,13 +596,30 @@ class EventQueueProcessorBase {
586
596
  this.#handleDelayedEvents(delayedEvents);
587
597
 
588
598
  result = openEvents;
589
- this.logger[eventsForProcessing.length ? "info" : "debug"]("Selected event queue entries for processing", {
590
- openEvents: openEvents.length,
591
- ...(delayedEvents.length && { delayedEvents: delayedEvents.length }),
592
- ...(exceededTries.length && { exceededTries: exceededTries.length }),
593
- eventType: this.#eventType,
594
- eventSubType: this.#eventSubType,
595
- });
599
+ this.logger[eventsForProcessing.length && !this.isPeriodicEvent ? "info" : "debug"](
600
+ "Selected event queue entries for processing",
601
+ {
602
+ openEvents: openEvents.length,
603
+ ...(delayedEvents.length && { delayedEvents: delayedEvents.length }),
604
+ ...(exceededTries.length && { exceededTries: exceededTries.length }),
605
+ eventType: this.#eventType,
606
+ eventSubType: this.#eventSubType,
607
+ }
608
+ );
609
+
610
+ if (this.#isPeriodic && exceededTries.length) {
611
+ await tx.run(
612
+ UPDATE.entity(this.#config.tableNameEventQueue)
613
+ .set({
614
+ status: EventProcessingStatus.Error,
615
+ lastAttemptTimestamp: new Date(),
616
+ })
617
+ .where(
618
+ "ID IN",
619
+ exceededTries.map(({ ID }) => ID)
620
+ )
621
+ );
622
+ }
596
623
 
597
624
  if (!eventsForProcessing.length) {
598
625
  this.__emptyChunkSelected = true;
@@ -623,10 +650,26 @@ class EventQueueProcessorBase {
623
650
  });
624
651
  this.__queueEntries = result;
625
652
  this.__queueEntriesMap = arrayToFlatMap(result);
653
+
654
+ if (this.#isPeriodic && this.#eventConfig.lastSuccessfulRunTimestamp) {
655
+ this.#lastSuccessfulRunTimestamp = await this.#selectLastSuccessfulPeriodicTimestamp(tx);
656
+ }
626
657
  });
627
658
  return result;
628
659
  }
629
660
 
661
+ async #selectLastSuccessfulPeriodicTimestamp() {
662
+ const entry = await SELECT.one
663
+ .from(this.#config.tableNameEventQueue)
664
+ .where({
665
+ type: this.#eventType,
666
+ subType: this.#eventSubType,
667
+ status: EventProcessingStatus.Done,
668
+ })
669
+ .columns("max (lastAttemptTimestamp) as lastAttemptsTs");
670
+ return entry.lastAttemptsTs;
671
+ }
672
+
630
673
  #handleDelayedEvents(delayedEvents) {
631
674
  for (const delayedEvent of delayedEvents) {
632
675
  this.#eventSchedulerInstance.scheduleEvent(
@@ -924,10 +967,37 @@ class EventQueueProcessorBase {
924
967
  obsoleteEntries.push(queueEntry);
925
968
  }
926
969
  }
927
- await this.setPeriodicEventStatus(obsoleteEntries.map(({ ID }) => ID));
970
+ await this.setPeriodicEventStatus(
971
+ obsoleteEntries.map(({ ID }) => ID),
972
+ EventProcessingStatus.Done
973
+ );
928
974
  return queueEntryToUse;
929
975
  }
930
976
 
977
+ /**
978
+ * Asynchronously gets the timestamp of the last successful run.
979
+ *
980
+ * @returns {Promise<string|null>} A Promise that resolves to a string representation of the timestamp
981
+ * of the last successful run (in ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sss),
982
+ * or null if there has been no successful run yet.
983
+ *
984
+ * @example
985
+ * const timestamp = await instance.getLastSuccessfulRunTimestamp();
986
+ * console.log(timestamp); // Outputs: 2023-12-07T09:15:44.237
987
+ *
988
+ * @throws {Error} If an error occurs while fetching the timestamp.
989
+ */
990
+ async getLastSuccessfulRunTimestamp() {
991
+ if (!this.#isPeriodic) {
992
+ return null;
993
+ }
994
+ if (this.#lastSuccessfulRunTimestamp === undefined) {
995
+ this.#lastSuccessfulRunTimestamp = await this.#selectLastSuccessfulPeriodicTimestamp();
996
+ }
997
+
998
+ return this.#lastSuccessfulRunTimestamp;
999
+ }
1000
+
931
1001
  statusMapContainsError(statusMap) {
932
1002
  return Object.values(statusMap).includes(EventProcessingStatus.Error);
933
1003
  }
package/src/config.js CHANGED
@@ -9,10 +9,24 @@ const EventQueueError = require("./EventQueueError");
9
9
  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
+ const REDIS_CONFIG_BLOCKLIST_CHANNEL = "REDIS_CONFIG_BLOCKLIST_CHANNEL";
12
13
  const COMPONENT_NAME = "eventQueue/config";
13
14
  const MIN_INTERVAL_SEC = 10;
14
15
  const DEFAULT_LOAD = 1;
15
16
  const SUFFIX_PERIODIC = "_PERIODIC";
17
+ const COMMAND_BLOCK = "EVENT_QUEUE_EVENT_BLOCK";
18
+ const COMMAND_UNBLOCK = "EVENT_QUEUE_EVENT_UNBLOCK";
19
+
20
+ const BASE_PERIODIC_EVENTS = [
21
+ {
22
+ type: "EVENT_QUEUE_BASE",
23
+ subType: "DELETE_EVENTS",
24
+ impl: "./housekeeping/EventQueueDeleteEvents",
25
+ load: 1,
26
+ interval: 86400, // 1 day,
27
+ internalEvent: true,
28
+ },
29
+ ];
16
30
 
17
31
  class Config {
18
32
  #logger;
@@ -25,7 +39,7 @@ class Config {
25
39
  #instanceLoadLimit;
26
40
  #tableNameEventQueue;
27
41
  #tableNameEventLock;
28
- #isRunnerDeactivated;
42
+ #isEventQueueActive;
29
43
  #configFilePath;
30
44
  #processEventsAfterPublish;
31
45
  #skipCsnCheck;
@@ -34,6 +48,9 @@ class Config {
34
48
  #env;
35
49
  #eventMap;
36
50
  #updatePeriodicEvents;
51
+ #blockedPeriodicEvents;
52
+ #isPeriodicEventBlockedCb;
53
+ #thresholdLoggingEventProcessing;
37
54
  static #instance;
38
55
  constructor() {
39
56
  this.#logger = cds.log(COMPONENT_NAME);
@@ -46,12 +63,13 @@ class Config {
46
63
  this.#instanceLoadLimit = 100;
47
64
  this.#tableNameEventQueue = null;
48
65
  this.#tableNameEventLock = null;
49
- this.#isRunnerDeactivated = false;
66
+ this.#isEventQueueActive = true;
50
67
  this.#configFilePath = null;
51
68
  this.#processEventsAfterPublish = null;
52
69
  this.#skipCsnCheck = null;
53
70
  this.#disableRedis = null;
54
71
  this.#env = getEnvInstance();
72
+ this.#blockedPeriodicEvents = {};
55
73
  }
56
74
 
57
75
  getEventConfig(type, subType) {
@@ -71,6 +89,7 @@ class Config {
71
89
  }
72
90
 
73
91
  attachConfigChangeHandler() {
92
+ this.#attachBlocklistChangeHandler();
74
93
  redis.subscribeRedisChannel(REDIS_CONFIG_CHANNEL, (messageData) => {
75
94
  try {
76
95
  const { key, value } = JSON.parse(messageData);
@@ -79,7 +98,7 @@ class Config {
79
98
  this[key] = value;
80
99
  }
81
100
  } catch (err) {
82
- this.#logger.error("could not parse event config change", {
101
+ this.#logger.error("could not parse event config change", err, {
83
102
  messageData,
84
103
  });
85
104
  }
@@ -96,18 +115,102 @@ class Config {
96
115
  });
97
116
  }
98
117
 
99
- get isRunnerDeactivated() {
100
- return this.#isRunnerDeactivated;
118
+ #attachBlocklistChangeHandler() {
119
+ redis.subscribeRedisChannel(REDIS_CONFIG_BLOCKLIST_CHANNEL, (messageData) => {
120
+ try {
121
+ const { command, key, tenant } = JSON.parse(messageData);
122
+ if (command === COMMAND_BLOCK) {
123
+ this.#blockPeriodicEventLocalState(key, tenant);
124
+ } else {
125
+ this.#unblockPeriodicEventLocalState(key, tenant);
126
+ }
127
+ } catch (err) {
128
+ this.#logger.error("could not parse event blocklist change", err, {
129
+ messageData,
130
+ });
131
+ }
132
+ });
133
+ }
134
+
135
+ blockPeriodicEvent(type, subType, tenant = "*") {
136
+ const typeWithSuffix = `${type}${SUFFIX_PERIODIC}`;
137
+ const config = this.getEventConfig(typeWithSuffix, subType);
138
+ if (!config) {
139
+ return;
140
+ }
141
+ const key = this.generateKey(typeWithSuffix, subType);
142
+ this.#blockPeriodicEventLocalState(key, tenant);
143
+ if (!this.redisEnabled) {
144
+ return;
145
+ }
146
+
147
+ redis
148
+ .publishMessage(REDIS_CONFIG_BLOCKLIST_CHANNEL, JSON.stringify({ command: COMMAND_BLOCK, key, tenant }))
149
+ .catch((error) => {
150
+ this.#logger.error(`publishing config block failed key: ${key}`, error);
151
+ });
152
+ }
153
+
154
+ #blockPeriodicEventLocalState(key, tenant) {
155
+ this.#blockedPeriodicEvents[key] ??= {};
156
+ this.#blockedPeriodicEvents[key][tenant] = true;
157
+ return key;
158
+ }
159
+
160
+ clearPeriodicEventBlockList() {
161
+ this.#blockedPeriodicEvents = {};
162
+ }
163
+
164
+ unblockPeriodicEvent(type, subType, tenant = "*") {
165
+ const typeWithSuffix = `${type}${SUFFIX_PERIODIC}`;
166
+ const key = this.generateKey(typeWithSuffix, subType);
167
+ const config = this.getEventConfig(typeWithSuffix, subType);
168
+ if (!config) {
169
+ return;
170
+ }
171
+ this.#unblockPeriodicEventLocalState(key, tenant);
172
+ if (!this.redisEnabled) {
173
+ return;
174
+ }
175
+
176
+ redis
177
+ .publishMessage(REDIS_CONFIG_BLOCKLIST_CHANNEL, JSON.stringify({ command: COMMAND_UNBLOCK, key, tenant }))
178
+ .catch((error) => {
179
+ this.#logger.error(`publishing config block failed key: ${key}`, error);
180
+ });
181
+ }
182
+
183
+ #unblockPeriodicEventLocalState(key, tenant) {
184
+ const map = this.#blockedPeriodicEvents[key];
185
+ if (!map) {
186
+ return;
187
+ }
188
+ this.#blockedPeriodicEvents[key][tenant] = false;
189
+ return key;
190
+ }
191
+
192
+ isPeriodicEventBlocked(type, subType, tenant) {
193
+ const map = this.#blockedPeriodicEvents[this.generateKey(type, subType)];
194
+ if (!map) {
195
+ return false;
196
+ }
197
+ const tenantSpecific = map[tenant];
198
+ const allTenants = map["*"];
199
+ return tenantSpecific ?? allTenants;
200
+ }
201
+
202
+ get isEventQueueActive() {
203
+ return this.#isEventQueueActive;
101
204
  }
102
205
 
103
- set isRunnerDeactivated(value) {
104
- this.#isRunnerDeactivated = value;
206
+ set isEventQueueActive(value) {
207
+ this.#isEventQueueActive = value;
105
208
  }
106
209
 
107
210
  set fileContent(config) {
108
211
  this.#config = config;
109
212
  config.events = config.events ?? [];
110
- config.periodicEvents = config.periodicEvents ?? [];
213
+ config.periodicEvents = (config.periodicEvents ?? []).concat(BASE_PERIODIC_EVENTS.map((event) => ({ ...event })));
111
214
  this.#eventMap = config.events.reduce((result, event) => {
112
215
  event.load = event.load ?? DEFAULT_LOAD;
113
216
  this.validateAdHocEvents(result, event);
@@ -222,6 +325,14 @@ class Config {
222
325
  this.#instanceLoadLimit = value;
223
326
  }
224
327
 
328
+ get isPeriodicEventBlockedCb() {
329
+ return this.#isPeriodicEventBlockedCb;
330
+ }
331
+
332
+ set isPeriodicEventBlockedCb(value) {
333
+ this.#isPeriodicEventBlockedCb = value;
334
+ }
335
+
225
336
  get tableNameEventQueue() {
226
337
  return this.#tableNameEventQueue;
227
338
  }
@@ -286,6 +397,14 @@ class Config {
286
397
  return this.#registerAsEventProcessor;
287
398
  }
288
399
 
400
+ set thresholdLoggingEventProcessing(value) {
401
+ this.#thresholdLoggingEventProcessing = value;
402
+ }
403
+
404
+ get thresholdLoggingEventProcessing() {
405
+ return this.#thresholdLoggingEventProcessing;
406
+ }
407
+
289
408
  get isMultiTenancy() {
290
409
  return !!cds.requires.multitenancy;
291
410
  }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+
3
+ const EventQueueBaseClass = require("../EventQueueProcessorBase");
4
+ const config = require("../config");
5
+ const eventConfig = require("../config");
6
+
7
+ const DELETE_DEFAULT_PERIOD_IN_DAYS = 7;
8
+ const DAY_IN_MS = 24 * 60 * 60 * 1000;
9
+
10
+ class EventQueueDeleteEvents extends EventQueueBaseClass {
11
+ constructor(context, eventType, eventSubType, config) {
12
+ super(context, eventType, eventSubType, config);
13
+ }
14
+
15
+ async processPeriodicEvent(processContext, key) {
16
+ const tx = this.getTxForEventProcessing(key);
17
+ const deleteByDaysMap = config.events.concat(config.periodicEvents).reduce((result, event) => {
18
+ if (!event.deleteFinishedEventsAfterDays) {
19
+ event.deleteFinishedEventsAfterDays = DELETE_DEFAULT_PERIOD_IN_DAYS;
20
+ }
21
+ result[event.deleteFinishedEventsAfterDays] ??= [];
22
+ result[event.deleteFinishedEventsAfterDays].push(event);
23
+ return result;
24
+ }, {});
25
+
26
+ for (const [deleteAfter, events] of Object.entries(deleteByDaysMap)) {
27
+ const deleteCount = await tx.run(
28
+ DELETE.from(eventConfig.tableNameEventQueue).where([
29
+ { list: [{ ref: ["type"] }, { ref: ["subType"] }] },
30
+ "IN",
31
+ {
32
+ list: events.map((event) => ({
33
+ list: [{ val: event.type }, { val: event.subType }],
34
+ })),
35
+ },
36
+ "AND",
37
+ { ref: ["lastAttemptTimestamp"] },
38
+ "<=",
39
+ { val: new Date(processContext.timestamp.getTime() - deleteAfter * DAY_IN_MS).toISOString() },
40
+ ])
41
+ );
42
+ this.logger.info("deleted eligible events", {
43
+ deleteAfterDays: deleteAfter,
44
+ deleteCount,
45
+ events: events.map((event) => `${event.type}_${event.subType}`),
46
+ });
47
+ }
48
+ }
49
+ }
50
+
51
+ module.exports = EventQueueDeleteEvents;
package/src/initialize.js CHANGED
@@ -27,26 +27,28 @@ const CONFIG_VARS = [
27
27
  ["configFilePath", null],
28
28
  ["registerAsEventProcessor", true],
29
29
  ["processEventsAfterPublish", true],
30
- ["isRunnerDeactivated", false],
30
+ ["isEventQueueActive", true],
31
31
  ["runInterval", 5 * 60 * 1000],
32
32
  ["tableNameEventQueue", BASE_TABLES.EVENT],
33
33
  ["tableNameEventLock", BASE_TABLES.LOCK],
34
34
  ["disableRedis", false],
35
35
  ["skipCsnCheck", false],
36
36
  ["updatePeriodicEvents", true],
37
+ ["thresholdLoggingEventProcessing", 50],
37
38
  ];
38
39
 
39
40
  const initialize = async ({
40
41
  configFilePath,
41
42
  registerAsEventProcessor,
42
43
  processEventsAfterPublish,
43
- isRunnerDeactivated,
44
+ isEventQueueActive,
44
45
  runInterval,
45
46
  tableNameEventQueue,
46
47
  tableNameEventLock,
47
48
  disableRedis,
48
49
  skipCsnCheck,
49
50
  updatePeriodicEvents,
51
+ thresholdLoggingEventProcessing,
50
52
  } = {}) => {
51
53
  // TODO: initialize check:
52
54
  // - content of yaml check
@@ -61,13 +63,14 @@ const initialize = async ({
61
63
  configFilePath,
62
64
  registerAsEventProcessor,
63
65
  processEventsAfterPublish,
64
- isRunnerDeactivated,
66
+ isEventQueueActive,
65
67
  runInterval,
66
68
  tableNameEventQueue,
67
69
  tableNameEventLock,
68
70
  disableRedis,
69
71
  skipCsnCheck,
70
- updatePeriodicEvents
72
+ updatePeriodicEvents,
73
+ thresholdLoggingEventProcessing
71
74
  );
72
75
 
73
76
  const logger = cds.log(COMPONENT);
@@ -5,7 +5,7 @@ const pathLib = require("path");
5
5
  const cds = require("@sap/cds");
6
6
 
7
7
  const config = require("./config");
8
- const { TransactionMode } = require("./constants");
8
+ const { TransactionMode, EventProcessingStatus } = require("./constants");
9
9
  const { limiter } = require("./shared/common");
10
10
 
11
11
  const { executeInNewTransaction, TriggerRollback } = require("./shared/cdsHelper");
@@ -20,7 +20,7 @@ const processEventQueue = async (context, eventType, eventSubType, startTime = n
20
20
  try {
21
21
  let eventTypeInstance;
22
22
  const eventConfig = config.getEventConfig(eventType, eventSubType);
23
- const [err, EventTypeClass] = resilientRequire(eventConfig?.impl);
23
+ const [err, EventTypeClass] = resilientRequire(eventConfig);
24
24
  if (!eventConfig || err || !(typeof EventTypeClass.constructor === "function")) {
25
25
  cds.log(COMPONENT_NAME).error("No Implementation found in the provided configuration file.", {
26
26
  eventType,
@@ -90,15 +90,6 @@ const processEventQueue = async (context, eventType, eventSubType, startTime = n
90
90
  await eventTypeInstance.persistEventStatus(tx);
91
91
  });
92
92
  shouldContinue = reevaluateShouldContinue(eventTypeInstance, iterationCounter, startTime);
93
- if (!shouldContinue) {
94
- await executeInNewTransaction(
95
- context,
96
- `eventQueue-deleteFinishedEvents-${eventType}##${eventSubType}`,
97
- async (tx) => {
98
- await eventTypeInstance.deleteFinishedEvents(tx);
99
- }
100
- );
101
- }
102
93
  }
103
94
  } catch (err) {
104
95
  cds.log(COMPONENT_NAME).error("Processing event queue failed with unexpected error.", err, {
@@ -124,11 +115,35 @@ const reevaluateShouldContinue = (eventTypeInstance, iterationCounter, startTime
124
115
  return false;
125
116
  };
126
117
 
127
- // TODO: don't forget to release lock
128
118
  const processPeriodicEvent = async (eventTypeInstance) => {
129
119
  let queueEntry;
130
120
  let processNext = true;
131
121
 
122
+ const isPeriodicEventBlockedCb = config.isPeriodicEventBlockedCb;
123
+ const params = [eventTypeInstance.eventType, eventTypeInstance.eventSubType, eventTypeInstance.context.tenant];
124
+ let eventBlocked = false;
125
+ if (isPeriodicEventBlockedCb) {
126
+ try {
127
+ eventBlocked = await isPeriodicEventBlockedCb(...params);
128
+ } catch (err) {
129
+ eventBlocked = true;
130
+ eventTypeInstance.logger.error("skipping run because periodic event blocked check failed!", err, {
131
+ type: eventTypeInstance.eventType,
132
+ subType: eventTypeInstance.eventSubType,
133
+ });
134
+ }
135
+ } else {
136
+ eventBlocked = config.isPeriodicEventBlocked(...params);
137
+ }
138
+
139
+ if (eventBlocked) {
140
+ eventTypeInstance.logger.info("skipping run because periodic event is blocked by configuration", {
141
+ type: eventTypeInstance.eventType,
142
+ subType: eventTypeInstance.eventSubType,
143
+ });
144
+ return;
145
+ }
146
+
132
147
  try {
133
148
  while (processNext) {
134
149
  await executeInNewTransaction(
@@ -153,6 +168,7 @@ const processPeriodicEvent = async (eventTypeInstance) => {
153
168
  return;
154
169
  }
155
170
 
171
+ let status = EventProcessingStatus.Done;
156
172
  await executeInNewTransaction(
157
173
  eventTypeInstance.context,
158
174
  `eventQueue-periodic-process-${eventTypeInstance.eventType}##${eventTypeInstance.eventSubType}`,
@@ -160,10 +176,14 @@ const processPeriodicEvent = async (eventTypeInstance) => {
160
176
  eventTypeInstance.processEventContext = tx.context;
161
177
  eventTypeInstance.setTxForEventProcessing(queueEntry.ID, cds.tx(tx.context));
162
178
  try {
179
+ eventTypeInstance.startPerformanceTracerPeriodicEvents();
163
180
  await eventTypeInstance.processPeriodicEvent(tx.context, queueEntry.ID, queueEntry);
164
181
  } catch (err) {
182
+ status = EventProcessingStatus.Error;
165
183
  eventTypeInstance.handleErrorDuringPeriodicEventProcessing(err, queueEntry);
166
184
  throw new TriggerRollback();
185
+ } finally {
186
+ eventTypeInstance.endPerformanceTracerPeriodicEvents();
167
187
  }
168
188
  if (
169
189
  eventTypeInstance.transactionMode === TransactionMode.alwaysRollback ||
@@ -179,7 +199,7 @@ const processPeriodicEvent = async (eventTypeInstance) => {
179
199
  `eventQueue-periodic-setStatus-${eventTypeInstance.eventType}##${eventTypeInstance.eventSubType}`,
180
200
  async (tx) => {
181
201
  eventTypeInstance.processEventContext = tx.context;
182
- await eventTypeInstance.setPeriodicEventStatus(queueEntry.ID);
202
+ await eventTypeInstance.setPeriodicEventStatus(queueEntry.ID, status);
183
203
  }
184
204
  );
185
205
  }
@@ -196,6 +216,7 @@ const processPeriodicEvent = async (eventTypeInstance) => {
196
216
  const processEventMap = async (eventTypeInstance) => {
197
217
  eventTypeInstance.startPerformanceTracerEvents();
198
218
  await eventTypeInstance.beforeProcessingEvents();
219
+ eventTypeInstance.logStartMessage();
199
220
  if (eventTypeInstance.commitOnEventLevel) {
200
221
  eventTypeInstance.txUsageAllowed = false;
201
222
  }
@@ -244,7 +265,6 @@ const processEventMap = async (eventTypeInstance) => {
244
265
 
245
266
  const _processEvent = async (eventTypeInstance, processContext, key, queueEntries, payload) => {
246
267
  try {
247
- eventTypeInstance.logStartMessage(queueEntries);
248
268
  const eventOutdated = await eventTypeInstance.isOutdatedAndKeepalive(queueEntries);
249
269
  if (eventOutdated) {
250
270
  return;
@@ -257,9 +277,11 @@ const _processEvent = async (eventTypeInstance, processContext, key, queueEntrie
257
277
  }
258
278
  };
259
279
 
260
- const resilientRequire = (path) => {
280
+ const resilientRequire = (eventConfig) => {
261
281
  try {
262
- const module = require(pathLib.join(process.cwd(), path));
282
+ const path = eventConfig?.impl;
283
+ const internal = eventConfig?.internalEvent;
284
+ const module = require(pathLib.join(internal ? __dirname : process.cwd(), path));
263
285
  return [null, module];
264
286
  } catch (err) {
265
287
  return [err, null];
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ const cds = require("@sap/cds");
4
+
3
5
  const redis = require("./shared/redis");
4
6
  const { checkLockExistsAndReturnValue } = require("./shared/distributedLock");
5
7
  const config = require("./config");
@@ -27,6 +29,14 @@ const messageHandlerProcessEvents = async (messageData) => {
27
29
  type,
28
30
  subType,
29
31
  });
32
+ if (!config.isEventQueueActive) {
33
+ cds.log(COMPONENT_NAME).info("Skipping processing because runner is deactivated!", {
34
+ type,
35
+ subType,
36
+ });
37
+ return;
38
+ }
39
+
30
40
  const subdomain = await getSubdomainForTenantId(tenantId);
31
41
  const tenantContext = {
32
42
  tenant: tenantId,
@@ -46,6 +56,13 @@ const messageHandlerProcessEvents = async (messageData) => {
46
56
  const broadcastEvent = async (tenantId, type, subType) => {
47
57
  const logger = cds.log(COMPONENT_NAME);
48
58
  try {
59
+ if (!config.isEventQueueActive) {
60
+ cds.log(COMPONENT_NAME).info("Skipping processing because runner is deactivated!", {
61
+ type,
62
+ subType,
63
+ });
64
+ return;
65
+ }
49
66
  if (!config.redisEnabled) {
50
67
  if (config.registerAsEventProcessor) {
51
68
  let context = {};
package/src/runner.js CHANGED
@@ -37,7 +37,7 @@ const _scheduleFunction = async (singleRunFn, periodicFn) => {
37
37
 
38
38
  const fnWithRunningCheck = () => {
39
39
  const logger = cds.log(COMPONENT_NAME);
40
- if (eventQueueConfig.isRunnerDeactivated) {
40
+ if (!eventQueueConfig.isEventQueueActive) {
41
41
  logger.info("runner is deactivated via config variable. Skipping this run.");
42
42
  return;
43
43
  }
@@ -82,6 +82,9 @@ const _checkAndTriggerPeriodicEventUpdate = (tenantIds) => {
82
82
  const hash = hashStringTo32Bit(JSON.stringify(tenantIds));
83
83
  if (!tenantIdHash) {
84
84
  tenantIdHash = hash;
85
+ _multiTenancyPeriodicEvents().catch((err) => {
86
+ cds.log(COMPONENT_NAME).error("Error during triggering updating periodic events!", err);
87
+ });
85
88
  return;
86
89
  }
87
90
  if (tenantIdHash && tenantIdHash !== hash) {
@@ -109,9 +112,10 @@ const _executeEventsAllTenants = (tenantIds, runId) => {
109
112
  http: { req: { authInfo: { getSubdomain: () => subdomain } } },
110
113
  };
111
114
  return await cds.tx(tenantContext, async ({ context }) => {
112
- return await WorkerQueue.instance.addToQueue(event.load, async () => {
115
+ const label = `${event.type}_${event.subType}`;
116
+ return await WorkerQueue.instance.addToQueue(event.load, label, async () => {
113
117
  try {
114
- const lockId = `${runId}_${event.type}_${event.subType}`;
118
+ const lockId = `${runId}_${label}`;
115
119
  const couldAcquireLock = await distributedLock.acquireLock(context, lockId, {
116
120
  expiryTime: eventQueueConfig.runInterval * 0.95,
117
121
  });
@@ -131,7 +135,8 @@ const _executeEventsAllTenants = (tenantIds, runId) => {
131
135
 
132
136
  const _executePeriodicEventsAllTenants = (tenantIds, runId) => {
133
137
  tenantIds.forEach((tenantId) => {
134
- WorkerQueue.instance.addToQueue(1, async () => {
138
+ const label = `UPDATE_PERIODIC_EVENTS_${tenantId}`;
139
+ WorkerQueue.instance.addToQueue(1, label, async () => {
135
140
  try {
136
141
  const subdomain = await getSubdomainForTenantId(tenantId);
137
142
  const tenantContext = {
@@ -159,11 +164,18 @@ const _executePeriodicEventsAllTenants = (tenantIds, runId) => {
159
164
  };
160
165
 
161
166
  const _singleTenantDb = async (tenantId) => {
162
- const events = eventQueueConfig.allEvents;
163
- events.forEach((event) => {
164
- WorkerQueue.instance.addToQueue(event.load, async () => {
167
+ return eventQueueConfig.allEvents.map((event) => {
168
+ const label = `${event.type}_${event.subType}`;
169
+ return WorkerQueue.instance.addToQueue(event.load, label, async () => {
165
170
  try {
166
171
  const context = new cds.EventContext({ tenant: tenantId });
172
+ const lockId = `${EVENT_QUEUE_RUN_ID}_${label}`;
173
+ const couldAcquireLock = await distributedLock.acquireLock(context, lockId, {
174
+ expiryTime: eventQueueConfig.runInterval * 0.95,
175
+ });
176
+ if (!couldAcquireLock) {
177
+ return;
178
+ }
167
179
  await runEventCombinationForTenant(context, event.type, event.subType, true);
168
180
  } catch (err) {
169
181
  cds.log(COMPONENT_NAME).error("executing event-queue run for tenant failed", {
@@ -238,8 +250,10 @@ const runEventCombinationForTenant = async (context, type, subType, skipWorkerPo
238
250
  return await processEventQueue(context, type, subType);
239
251
  } else {
240
252
  const config = eventQueueConfig.getEventConfig(type, subType);
253
+ const label = `${type}_${subType}`;
241
254
  return await WorkerQueue.instance.addToQueue(
242
255
  config.load,
256
+ label,
243
257
  async () => await processEventQueue(context, type, subType)
244
258
  );
245
259
  }
@@ -307,10 +321,12 @@ module.exports = {
307
321
  multiTenancyRedis,
308
322
  runEventCombinationForTenant,
309
323
  _: {
324
+ _singleTenantDb,
310
325
  _multiTenancyRedis,
311
326
  _multiTenancyDb,
312
327
  _calculateOffsetForFirstRun,
313
328
  _acquireRunId,
314
329
  EVENT_QUEUE_RUN_TS,
330
+ clearHash: () => (tenantIdHash = null),
315
331
  },
316
332
  };
@@ -17,6 +17,7 @@ class PerformanceTracer {
17
17
  this.__start = new Date();
18
18
  this.__logger = logger;
19
19
  this.__name = name;
20
+ this.__properties = options.properties ??= {};
20
21
  options.startMessage &&
21
22
  logger.info("Performance measurement started", {
22
23
  name: name,
@@ -64,6 +65,7 @@ class PerformanceTracer {
64
65
  this.__logger.info("Performance measurement executed", {
65
66
  name: this.__name,
66
67
  milliseconds: executionTime,
68
+ ...this.__properties,
67
69
  customFields,
68
70
  });
69
71
  }
@@ -8,9 +8,9 @@ const EventQueueError = require("../EventQueueError");
8
8
  const COMPONENT_NAME = "eventQueue/WorkerQueue";
9
9
  const NANO_TO_MS = 1e6;
10
10
  const THRESHOLD = {
11
- INFO: 15 * 1000,
12
- WARN: 30 * 1000,
13
- ERROR: 45 * 1000,
11
+ INFO: 35 * 1000,
12
+ WARN: 55 * 1000,
13
+ ERROR: 75 * 1000,
14
14
  };
15
15
 
16
16
  class WorkerQueue {
@@ -31,21 +31,21 @@ class WorkerQueue {
31
31
  this.#queue = [];
32
32
  }
33
33
 
34
- addToQueue(load, cb) {
34
+ addToQueue(load, label, cb) {
35
35
  if (load > this.#concurrencyLimit) {
36
- throw EventQueueError.loadHigherThanLimit(load);
36
+ throw EventQueueError.loadHigherThanLimit(load, label);
37
37
  }
38
38
 
39
39
  const startTime = process.hrtime.bigint();
40
40
  const p = new Promise((resolve, reject) => {
41
- this.#queue.push([load, cb, resolve, reject, startTime]);
41
+ this.#queue.push([load, label, cb, resolve, reject, startTime]);
42
42
  });
43
43
  this._checkForNext();
44
44
  return p;
45
45
  }
46
46
 
47
- _executeFunction(load, cb, resolve, reject, startTime) {
48
- this.checkAndLogWaitingTime(startTime);
47
+ _executeFunction(load, label, cb, resolve, reject, startTime) {
48
+ this.checkAndLogWaitingTime(startTime, label);
49
49
  const promise = Promise.resolve().then(() => cb());
50
50
  this.#runningPromises.push(promise);
51
51
  this.#runningLoad = this.#runningLoad + load;
@@ -59,7 +59,7 @@ class WorkerQueue {
59
59
  resolve(...results);
60
60
  })
61
61
  .catch((err) => {
62
- cds.log(COMPONENT_NAME).error("Error happened in WorkQueue. Errors should be caught before!", err);
62
+ cds.log(COMPONENT_NAME).error("Error happened in WorkQueue. Errors should be caught before!", err, { label });
63
63
  reject(err);
64
64
  });
65
65
  }
@@ -87,7 +87,7 @@ class WorkerQueue {
87
87
  return WorkerQueue.#instance;
88
88
  }
89
89
 
90
- checkAndLogWaitingTime(startTime) {
90
+ checkAndLogWaitingTime(startTime, label) {
91
91
  const diffMs = Math.round(Number(process.hrtime.bigint() - startTime) / NANO_TO_MS);
92
92
  let logLevel;
93
93
  if (diffMs >= THRESHOLD.ERROR) {
@@ -101,6 +101,7 @@ class WorkerQueue {
101
101
  }
102
102
  cds.log(COMPONENT_NAME)[logLevel]("Waiting time in worker queue", {
103
103
  diffMs,
104
+ label,
104
105
  });
105
106
  }
106
107
  }
@@ -80,7 +80,7 @@ const subscribeRedisChannel = (channel, subscribeCb) => {
80
80
  subscriberChannelClientPromise[channel]
81
81
  .then((client) => {
82
82
  cds.log(COMPONENT_NAME).info("subscribe redis client connected channel", { channel });
83
- client.subscribe(channel, subscribeCb);
83
+ client.subscribe(channel, subscribeCb).catch(errorHandlerCreateClient);
84
84
  })
85
85
  .catch((err) => {
86
86
  cds