@cap-js-community/event-queue 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@cap-js-community/event-queue)](https://www.npmjs.com/package/@cap-js-community/event-queue)
4
4
  [![monthly downloads](https://img.shields.io/npm/dm/@cap-js-community/event-queue)](https://www.npmjs.com/package/@cap-js-community/event-queue)
5
5
  [![REUSE status](https://api.reuse.software/badge/github.com/cap-js-community/event-queue)](https://api.reuse.software/info/github.com/cap-js-community/event-queue)
6
- [![CI Main](https://github.com/cap-js-community/event-queue/actions/workflows/ci-main.yml/badge.svg)](https://github.com/cap-js-community/event-queue/commits/main)
6
+ [![CI Main](https://github.com/cap-js-community/event-queue/actions/workflows/main-ci.yml/badge.svg)](https://github.com/cap-js-community/event-queue/commits/main)
7
7
 
8
8
  The Event-Queue is a framework built on top of CAP Node.js, specifically designed to enable efficient and streamlined
9
9
  asynchronous event processing in a multi-tenancy environment. With a strong emphasis on load balancing, this package
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": [
@@ -12,6 +12,10 @@ const ERROR_CODES = {
12
12
  MISSING_ELEMENT_IN_TABLE: "MISSING_ELEMENT_IN_TABLE",
13
13
  TYPE_MISMATCH_TABLE: "TYPE_MISMATCH_TABLE",
14
14
  NO_VALID_DATE: "NO_VALID_DATE",
15
+ INVALID_INTERVAL: "INVALID_INTERVAL",
16
+ MISSING_IMPL: "MISSING_IMPL",
17
+ DUPLICATE_EVENT_REGISTRATION: "DUPLICATE_EVENT_REGISTRATION",
18
+ NO_MANUEL_INSERT_OF_PERIODIC: "NO_MANUEL_INSERT_OF_PERIODIC",
15
19
  };
16
20
 
17
21
  const ERROR_CODES_META = {
@@ -43,6 +47,18 @@ const ERROR_CODES_META = {
43
47
  [ERROR_CODES.NO_VALID_DATE]: {
44
48
  message: "One or more events contain a date in a malformed format.",
45
49
  },
50
+ [ERROR_CODES.INVALID_INTERVAL]: {
51
+ message: "Invalid interval, the value needs to greater than 10 seconds.",
52
+ },
53
+ [ERROR_CODES.MISSING_IMPL]: {
54
+ message: "Missing path to event class implementation.",
55
+ },
56
+ [ERROR_CODES.DUPLICATE_EVENT_REGISTRATION]: {
57
+ message: "Duplicate event registration, check the uniqueness of type and subType.",
58
+ },
59
+ [ERROR_CODES.NO_MANUEL_INSERT_OF_PERIODIC]: {
60
+ message: "Periodic events are managed by the framework and are not allowed to insert manually.",
61
+ },
46
62
  };
47
63
 
48
64
  class EventQueueError extends VError {
@@ -146,6 +162,50 @@ class EventQueueError extends VError {
146
162
  message
147
163
  );
148
164
  }
165
+
166
+ static invalidInterval(type, subType, interval) {
167
+ const { message } = ERROR_CODES_META[ERROR_CODES.INVALID_INTERVAL];
168
+ return new EventQueueError(
169
+ {
170
+ name: ERROR_CODES.INVALID_INTERVAL,
171
+ info: { type, subType, interval },
172
+ },
173
+ message
174
+ );
175
+ }
176
+
177
+ static missingImpl(type, subType) {
178
+ const { message } = ERROR_CODES_META[ERROR_CODES.MISSING_IMPL];
179
+ return new EventQueueError(
180
+ {
181
+ name: ERROR_CODES.MISSING_IMPL,
182
+ info: { type, subType },
183
+ },
184
+ message
185
+ );
186
+ }
187
+
188
+ static duplicateEventRegistration(type, subType) {
189
+ const { message } = ERROR_CODES_META[ERROR_CODES.DUPLICATE_EVENT_REGISTRATION];
190
+ return new EventQueueError(
191
+ {
192
+ name: ERROR_CODES.DUPLICATE_EVENT_REGISTRATION,
193
+ info: { type, subType },
194
+ },
195
+ message
196
+ );
197
+ }
198
+
199
+ static manuelPeriodicEventInsert(type, subType) {
200
+ const { message } = ERROR_CODES_META[ERROR_CODES.NO_MANUEL_INSERT_OF_PERIODIC];
201
+ return new EventQueueError(
202
+ {
203
+ name: ERROR_CODES.NO_MANUEL_INSERT_OF_PERIODIC,
204
+ info: { type, subType },
205
+ },
206
+ message
207
+ );
208
+ }
149
209
  }
150
210
 
151
211
  module.exports = EventQueueError;
@@ -7,7 +7,7 @@ const { EventProcessingStatus, TransactionMode } = require("./constants");
7
7
  const distributedLock = require("./shared/distributedLock");
8
8
  const EventQueueError = require("./EventQueueError");
9
9
  const { arrayToFlatMap } = require("./shared/common");
10
- const eventScheduler = require("./shared/EventScheduler");
10
+ const eventScheduler = require("./shared/eventScheduler");
11
11
  const eventQueueConfig = require("./config");
12
12
  const PerformanceTracer = require("./shared/PerformanceTracer");
13
13
 
@@ -303,6 +303,29 @@ class EventQueueProcessorBase {
303
303
  return Object.fromEntries(queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Error]));
304
304
  }
305
305
 
306
+ handleErrorDuringPeriodicEventProcessing(error, queueEntry) {
307
+ this.logger.error(
308
+ `Caught error during event periodic processing. Please catch your promises/exceptions. Error: ${error}`,
309
+ {
310
+ eventType: this.#eventType,
311
+ eventSubType: this.#eventSubType,
312
+ queueEntryId: queueEntry.ID,
313
+ }
314
+ );
315
+ }
316
+
317
+ async setPeriodicEventStatus(queueEntryIds) {
318
+ await this.tx.run(
319
+ UPDATE.entity(this.#config.tableNameEventQueue)
320
+ .set({
321
+ status: EventProcessingStatus.Done,
322
+ })
323
+ .where({
324
+ ID: queueEntryIds,
325
+ })
326
+ );
327
+ }
328
+
306
329
  /**
307
330
  * This function validates for all selected events one status has been submitted. It's also validated that only for
308
331
  * selected events a status has been submitted. Persisting the status of events is done in a dedicated database tx.
@@ -812,6 +835,47 @@ class EventQueueProcessorBase {
812
835
  }
813
836
  }
814
837
 
838
+ async scheduleNextPeriodEvent(queueEntry) {
839
+ const interval = this.__eventConfig.interval;
840
+ const newEvent = {
841
+ type: this.#eventType,
842
+ subType: this.#eventSubType,
843
+ startAfter: new Date(new Date(queueEntry.startAfter).getTime() + interval * 1000),
844
+ };
845
+ this.tx._skipEventQueueBroadcase = true;
846
+ await this.tx.run(INSERT.into(this.#config.tableNameEventQueue).entries({ ...newEvent }));
847
+ this.tx._skipEventQueueBroadcase = false;
848
+ if (interval < this.#config.runInterval) {
849
+ this.#handleDelayedEvents([newEvent]);
850
+ }
851
+ }
852
+
853
+ async handleDuplicatedPeriodicEventEntry(queueEntries) {
854
+ this.logger.error("More than one open events for the same configuration which is not allowed!", {
855
+ eventType: this.#eventType,
856
+ eventSubType: this.#eventSubType,
857
+ queueEntriesIds: queueEntries.map(({ ID }) => ID),
858
+ });
859
+
860
+ let queueEntryToUse;
861
+ const obsoleteEntries = [];
862
+ for (const queueEntry of queueEntries) {
863
+ if (!queueEntryToUse) {
864
+ queueEntryToUse = queueEntry;
865
+ continue;
866
+ }
867
+
868
+ if (queueEntryToUse.startAfter <= queueEntry.queueEntry) {
869
+ obsoleteEntries.push(queueEntryToUse);
870
+ queueEntryToUse = queueEntry;
871
+ } else {
872
+ obsoleteEntries.push(queueEntry);
873
+ }
874
+ }
875
+ await this.setPeriodicEventStatus(obsoleteEntries.map(({ ID }) => ID));
876
+ return queueEntryToUse;
877
+ }
878
+
815
879
  statusMapContainsError(statusMap) {
816
880
  return Object.values(statusMap).includes(EventProcessingStatus.Error);
817
881
  }
@@ -920,6 +984,10 @@ class EventQueueProcessorBase {
920
984
  setTxForEventProcessing(key, tx) {
921
985
  this.__txMap[key] = tx;
922
986
  }
987
+
988
+ get isPeriodicEvent() {
989
+ return this.__eventConfig.isPeriodic;
990
+ }
923
991
  }
924
992
 
925
993
  module.exports = EventQueueProcessorBase;
package/src/config.js CHANGED
@@ -4,6 +4,7 @@ const cds = require("@sap/cds");
4
4
 
5
5
  const { getEnvInstance } = require("./shared/env");
6
6
  const redis = require("./shared/redis");
7
+ const EventQueueError = require("./EventQueueError");
7
8
 
8
9
  let instance;
9
10
 
@@ -11,6 +12,7 @@ const FOR_UPDATE_TIMEOUT = 10;
11
12
  const GLOBAL_TX_TIMEOUT = 30 * 60 * 1000;
12
13
  const REDIS_CONFIG_CHANNEL = "EVENT_QUEUE_CONFIG_CHANNEL";
13
14
  const COMPONENT_NAME = "eventQueue/config";
15
+ const MIN_INTERVAL_SEC = 10;
14
16
 
15
17
  class Config {
16
18
  #logger;
@@ -30,6 +32,7 @@ class Config {
30
32
  #disableRedis;
31
33
  #env;
32
34
  #eventMap;
35
+ #updatePeriodicEvents;
33
36
  constructor() {
34
37
  this.#logger = cds.log(COMPONENT_NAME);
35
38
  this.#config = null;
@@ -50,11 +53,11 @@ class Config {
50
53
  }
51
54
 
52
55
  getEventConfig(type, subType) {
53
- return this.#eventMap[[type, subType].join("##")];
56
+ return this.#eventMap[this.generateKey(type, subType)];
54
57
  }
55
58
 
56
59
  hasEventAfterCommitFlag(type, subType) {
57
- return this.#eventMap[[type, subType].join("##")]?.processAfterCommit ?? true;
60
+ return this.#eventMap[this.generateKey(type, subType)]?.processAfterCommit ?? true;
58
61
  }
59
62
 
60
63
  _checkRedisIsBound() {
@@ -101,10 +104,47 @@ class Config {
101
104
 
102
105
  set fileContent(config) {
103
106
  this.#config = config;
107
+ config.events = config.events ?? [];
108
+ config.periodicEvents = config.periodicEvents ?? [];
104
109
  this.#eventMap = config.events.reduce((result, event) => {
110
+ this.validateAdHocEvents(result, event);
105
111
  result[[event.type, event.subType].join("##")] = event;
106
112
  return result;
107
113
  }, {});
114
+ this.#eventMap = config.periodicEvents.reduce((result, event) => {
115
+ this.validatePeriodicConfig(result, event);
116
+ event.isPeriodic = true;
117
+ result[[event.type, event.subType].join("##")] = event;
118
+ return result;
119
+ }, this.#eventMap);
120
+ }
121
+
122
+ validatePeriodicConfig(eventMap, config) {
123
+ if (eventMap[this.generateKey(config.type, config.subType)]) {
124
+ throw EventQueueError.duplicateEventRegistration(config.type, config.subType);
125
+ }
126
+
127
+ if (!config.interval || config.interval <= MIN_INTERVAL_SEC) {
128
+ throw EventQueueError.invalidInterval(config.type, config.subType, config.interval);
129
+ }
130
+
131
+ if (!config.impl) {
132
+ throw EventQueueError.missingImpl(config.type, config.subType);
133
+ }
134
+ }
135
+
136
+ validateAdHocEvents(eventMap, config) {
137
+ if (eventMap[this.generateKey(config.type, config.subType)]) {
138
+ throw EventQueueError.duplicateEventRegistration(config.type, config.subType);
139
+ }
140
+
141
+ if (!config.impl) {
142
+ throw EventQueueError.missingImpl(config.type, config.subType);
143
+ }
144
+ }
145
+
146
+ generateKey(type, subType) {
147
+ return [type, subType].join("##");
108
148
  }
109
149
 
110
150
  get fileContent() {
@@ -115,6 +155,18 @@ class Config {
115
155
  return this.#config.events;
116
156
  }
117
157
 
158
+ get periodicEvents() {
159
+ return this.#config.periodicEvents;
160
+ }
161
+
162
+ isPeriodicEvent(type, subType) {
163
+ return this.#eventMap[this.generateKey(type, subType)]?.isPeriodic;
164
+ }
165
+
166
+ get allEvents() {
167
+ return this.#config.events.concat(this.#config.periodicEvents);
168
+ }
169
+
118
170
  get forUpdateTimeout() {
119
171
  return this.#forUpdateTimeout;
120
172
  }
@@ -211,6 +263,14 @@ class Config {
211
263
  return this.#disableRedis;
212
264
  }
213
265
 
266
+ set updatePeriodicEvents(value) {
267
+ this.#updatePeriodicEvents = value;
268
+ }
269
+
270
+ get updatePeriodicEvents() {
271
+ return this.#updatePeriodicEvents;
272
+ }
273
+
214
274
  get isMultiTenancy() {
215
275
  return !!cds.requires.multitenancy;
216
276
  }
package/src/dbHandler.js CHANGED
@@ -11,6 +11,9 @@ const registerEventQueueDbHandler = (dbService) => {
11
11
  const configInstance = config.getConfigInstance();
12
12
  const def = dbService.model.definitions[configInstance.tableNameEventQueue];
13
13
  dbService.after("CREATE", def, (_, req) => {
14
+ if (req.tx._skipEventQueueBroadcase) {
15
+ return;
16
+ }
14
17
  req.tx._ = req.tx._ ?? {};
15
18
  req.tx._.eventQueuePublishEvents = req.tx._.eventQueuePublishEvents ?? {};
16
19
  const eventQueuePublishEvents = req.tx._.eventQueuePublishEvents;
package/src/initialize.js CHANGED
@@ -12,7 +12,8 @@ const EventQueueError = require("./EventQueueError");
12
12
  const runner = require("./runner");
13
13
  const dbHandler = require("./dbHandler");
14
14
  const { getConfigInstance } = require("./config");
15
- const { initEventQueueRedisSubscribe } = require("./redisPubSub");
15
+ const { initEventQueueRedisSubscribe, closeSubscribeClient } = require("./redisPubSub");
16
+ const { closeMainClient } = require("./shared/redis");
16
17
 
17
18
  const readFileAsync = promisify(fs.readFile);
18
19
 
@@ -33,6 +34,7 @@ const CONFIG_VARS = [
33
34
  ["tableNameEventLock", BASE_TABLES.LOCK],
34
35
  ["disableRedis", false],
35
36
  ["skipCsnCheck", false],
37
+ ["updatePeriodicEvents", true],
36
38
  ];
37
39
 
38
40
  const initialize = async ({
@@ -46,6 +48,7 @@ const initialize = async ({
46
48
  tableNameEventLock,
47
49
  disableRedis,
48
50
  skipCsnCheck,
51
+ updatePeriodicEvents,
49
52
  } = {}) => {
50
53
  // TODO: initialize check:
51
54
  // - content of yaml check
@@ -67,7 +70,8 @@ const initialize = async ({
67
70
  tableNameEventQueue,
68
71
  tableNameEventLock,
69
72
  disableRedis,
70
- skipCsnCheck
73
+ skipCsnCheck,
74
+ updatePeriodicEvents
71
75
  );
72
76
 
73
77
  const logger = cds.log(COMPONENT);
@@ -82,6 +86,7 @@ const initialize = async ({
82
86
  }
83
87
 
84
88
  registerEventProcessors();
89
+ registerCdsShutdown();
85
90
  logger.info("event queue initialized", {
86
91
  registerAsEventProcessor: configInstance.registerAsEventProcessor,
87
92
  multiTenancyEnabled: configInstance.isMultiTenancy,
@@ -115,17 +120,19 @@ const registerEventProcessors = () => {
115
120
  return;
116
121
  }
117
122
 
123
+ const errorHandler = (err) => cds.log(COMPONENT).error("error during init runner", err);
124
+
118
125
  if (!configInstance.isMultiTenancy) {
119
- runner.singleTenant();
126
+ runner.singleTenant().catch(errorHandler);
120
127
  return;
121
128
  }
122
129
 
123
130
  if (configInstance.redisEnabled) {
124
131
  initEventQueueRedisSubscribe();
125
132
  configInstance.attachConfigChangeHandler();
126
- runner.multiTenancyRedis();
133
+ runner.multiTenancyRedis().catch(errorHandler);
127
134
  } else {
128
- runner.multiTenancyDb();
135
+ runner.multiTenancyDb().catch(errorHandler);
129
136
  }
130
137
  };
131
138
 
@@ -181,6 +188,12 @@ const mixConfigVarsWithEnv = (...args) => {
181
188
  });
182
189
  };
183
190
 
191
+ const registerCdsShutdown = () => {
192
+ cds.on("shutdown", async () => {
193
+ await Promise.allSettled([closeMainClient(), closeSubscribeClient()]);
194
+ });
195
+ };
196
+
184
197
  module.exports = {
185
198
  initialize,
186
199
  };
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+
3
+ const cds = require("@sap/cds");
4
+
5
+ const { EventProcessingStatus } = require("./constants");
6
+ const { processChunkedSync } = require("./shared/common");
7
+ const { getConfigInstance } = require("./config");
8
+
9
+ const COMPONENT_NAME = "eventQueue/periodicEvents";
10
+
11
+ const checkAndInsertPeriodicEvents = async (context) => {
12
+ const tx = cds.tx(context);
13
+ const configInstance = getConfigInstance();
14
+ const baseCqn = SELECT.from(configInstance.tableNameEventQueue)
15
+ .where([
16
+ { list: [{ ref: ["type"] }, { ref: ["subType"] }] },
17
+ "IN",
18
+ {
19
+ list: configInstance.periodicEvents.map((periodicEvent) => ({
20
+ list: [{ val: periodicEvent.type }, { val: periodicEvent.subType }],
21
+ })),
22
+ },
23
+ "AND",
24
+ { ref: ["status"] },
25
+ "=",
26
+ { val: EventProcessingStatus.Open },
27
+ ])
28
+ .columns(["ID", "type", "subType", "startAfter"]);
29
+ const currentPeriodEvents = await tx.run(baseCqn);
30
+
31
+ if (!currentPeriodEvents.length) {
32
+ // fresh insert all
33
+ return await insertPeriodEvents(tx, configInstance.periodicEvents);
34
+ }
35
+
36
+ const exitingEventMap = currentPeriodEvents.reduce((result, current) => {
37
+ const key = _generateKey(current);
38
+ result[key] = current;
39
+ return result;
40
+ }, {});
41
+
42
+ const { newEvents, existingEvents } = configInstance.periodicEvents.reduce(
43
+ (result, event) => {
44
+ if (exitingEventMap[_generateKey(event)]) {
45
+ result.existingEvents.push(exitingEventMap[_generateKey(event)]);
46
+ } else {
47
+ result.newEvents.push(event);
48
+ }
49
+ return result;
50
+ },
51
+ { newEvents: [], existingEvents: [] }
52
+ );
53
+
54
+ const currentDate = new Date();
55
+ const exitingWithNotMatchingInterval = existingEvents.filter((existingEvent) => {
56
+ const config = configInstance.getEventConfig(existingEvent.type, existingEvent.subType);
57
+ const eventStartAfter = new Date(existingEvent.startAfter);
58
+ // check if to far in future
59
+ const dueInWithNewInterval = new Date(currentDate.getTime() + config.interval * 1000);
60
+ return eventStartAfter >= dueInWithNewInterval;
61
+ });
62
+
63
+ exitingWithNotMatchingInterval.length &&
64
+ cds.log(COMPONENT_NAME).info("deleting periodic events because they have changed", {
65
+ changedEvents: exitingWithNotMatchingInterval.map(({ type, subType }) => ({ type, subType })),
66
+ });
67
+ await tx.run(
68
+ DELETE.from(configInstance.tableNameEventQueue).where(
69
+ "ID IN",
70
+ exitingWithNotMatchingInterval.map(({ ID }) => ID)
71
+ )
72
+ );
73
+
74
+ const newOrChangedEvents = newEvents.concat(exitingWithNotMatchingInterval);
75
+
76
+ if (!newOrChangedEvents.length) {
77
+ return;
78
+ }
79
+
80
+ return await insertPeriodEvents(tx, newOrChangedEvents);
81
+ };
82
+
83
+ const insertPeriodEvents = async (tx, events) => {
84
+ const startAfter = new Date();
85
+ const configInstance = getConfigInstance();
86
+ processChunkedSync(events, 4, (chunk) => {
87
+ cds.log(COMPONENT_NAME).info("inserting changed or new periodic events", {
88
+ events: chunk.map(({ type, subType }) => {
89
+ const { interval } = configInstance.getEventConfig(type, subType);
90
+ return { type, subType, interval };
91
+ }),
92
+ });
93
+ });
94
+ const periodEventsInsert = events.map((periodicEvent) => ({
95
+ type: periodicEvent.type,
96
+ subType: periodicEvent.subType,
97
+ startAfter: startAfter,
98
+ }));
99
+
100
+ tx._skipEventQueueBroadcase = true;
101
+ await tx.run(INSERT.into(configInstance.tableNameEventQueue).entries(periodEventsInsert));
102
+ tx._skipEventQueueBroadcase = false;
103
+ };
104
+
105
+ const _generateKey = ({ type, subType }) => [type, subType].join("##");
106
+
107
+ module.exports = {
108
+ checkAndInsertPeriodicEvents,
109
+ };
@@ -43,6 +43,9 @@ const processEventQueue = async (context, eventType, eventSubType, startTime = n
43
43
  if (!continueProcessing) {
44
44
  return;
45
45
  }
46
+ if (baseInstance.isPeriodicEvent) {
47
+ return await processPeriodicEvent(baseInstance);
48
+ }
46
49
  eventConfig.startTime = startTime;
47
50
  while (shouldContinue) {
48
51
  iterationCounter++;
@@ -131,6 +134,66 @@ const reevaluateShouldContinue = (eventTypeInstance, iterationCounter, startTime
131
134
  return false;
132
135
  };
133
136
 
137
+ // TODO: don't forget to release lock
138
+ const processPeriodicEvent = async (eventTypeInstance) => {
139
+ let queueEntry;
140
+ try {
141
+ await executeInNewTransaction(
142
+ eventTypeInstance.context,
143
+ `eventQueue-periodic-scheduleNext-${eventTypeInstance.eventType}##${eventTypeInstance.eventSubType}`,
144
+ async (tx) => {
145
+ eventTypeInstance.processEventContext = tx.context;
146
+ const queueEntries = await eventTypeInstance.getQueueEntriesAndSetToInProgress();
147
+ if (!queueEntries.length) {
148
+ return;
149
+ }
150
+ if (queueEntries.length > 1) {
151
+ queueEntry = await eventTypeInstance.handleDuplicatedPeriodicEventEntry(queueEntries);
152
+ } else {
153
+ queueEntry = queueEntries[0];
154
+ }
155
+ await eventTypeInstance.scheduleNextPeriodEvent(queueEntry);
156
+ }
157
+ );
158
+
159
+ if (!queueEntry) {
160
+ return;
161
+ }
162
+
163
+ await executeInNewTransaction(
164
+ eventTypeInstance.context,
165
+ `eventQueue-periodic-process-${eventTypeInstance.eventType}##${eventTypeInstance.eventSubType}`,
166
+ async (tx) => {
167
+ eventTypeInstance.processEventContext = tx.context;
168
+ eventTypeInstance.setTxForEventProcessing(queueEntry.ID, cds.tx(tx.context));
169
+ try {
170
+ await eventTypeInstance.processEvent(tx.context, queueEntry.ID, [queueEntry]);
171
+ } catch (err) {
172
+ eventTypeInstance.handleErrorDuringPeriodicEventProcessing(err, queueEntry);
173
+ throw new TriggerRollback();
174
+ }
175
+ if (
176
+ eventTypeInstance.transactionMode !== TransactionMode.alwaysCommit ||
177
+ eventTypeInstance.shouldRollbackTransaction(queueEntry.ID)
178
+ ) {
179
+ throw new TriggerRollback();
180
+ }
181
+ }
182
+ );
183
+
184
+ await executeInNewTransaction(
185
+ eventTypeInstance.context,
186
+ `eventQueue-periodic-setStatus-${eventTypeInstance.eventType}##${eventTypeInstance.eventSubType}`,
187
+ async (tx) => {
188
+ eventTypeInstance.processEventContext = tx.context;
189
+ await eventTypeInstance.setPeriodicEventStatus(queueEntry.ID);
190
+ }
191
+ );
192
+ } finally {
193
+ await eventTypeInstance?.handleReleaseLock();
194
+ }
195
+ };
196
+
134
197
  const processEventMap = async (eventTypeInstance) => {
135
198
  eventTypeInstance.startPerformanceTracerEvents();
136
199
  await eventTypeInstance.beforeProcessingEvents();
@@ -21,12 +21,13 @@ const EventQueueError = require("./EventQueueError");
21
21
  * createdAt: Timestamp, // Timestamp of event creation. This field is automatically set on insert.
22
22
  * startAfter: Timestamp, // Timestamp indicating when the event should start after.
23
23
  * }
24
+ * @param {Boolean} skipBroadcast - (Optional) If set to true, event broadcasting will be skipped. Defaults to false.
24
25
  * @throws {EventQueueError} Throws an error if the configuration is not initialized.
25
26
  * @throws {EventQueueError} Throws an error if the event type is unknown.
26
27
  * @throws {EventQueueError} Throws an error if the startAfter field is not a valid date.
27
28
  * @returns {Promise} Returns a promise which resolves to the result of the database insert operation.
28
29
  */
29
- const publishEvent = async (tx, events) => {
30
+ const publishEvent = async (tx, events, skipBroadcast = false) => {
30
31
  const configInstance = config.getConfigInstance();
31
32
  if (!configInstance.initialized) {
32
33
  throw EventQueueError.notInitialized();
@@ -40,8 +41,15 @@ const publishEvent = async (tx, events) => {
40
41
  if (startAfter && !common.isValidDate(startAfter)) {
41
42
  throw EventQueueError.malformedDate(startAfter);
42
43
  }
44
+
45
+ if (eventConfig.isPeriodic) {
46
+ throw EventQueueError.manuelPeriodicEventInsert(type, subType);
47
+ }
43
48
  }
44
- return await tx.run(INSERT.into(configInstance.tableNameEventQueue).entries(eventsForProcessing));
49
+ tx._skipEventQueueBroadcase = skipBroadcast;
50
+ const result = await tx.run(INSERT.into(configInstance.tableNameEventQueue).entries(eventsForProcessing));
51
+ tx._skipEventQueueBroadcase = false;
52
+ return result;
45
53
  };
46
54
 
47
55
  module.exports = {
@@ -67,7 +67,19 @@ const broadcastEvent = async (tenantId, type, subType) => {
67
67
  }
68
68
  };
69
69
 
70
+ const closeSubscribeClient = async () => {
71
+ try {
72
+ const client = await subscriberClientPromise;
73
+ if (client?.quit) {
74
+ await client.quit();
75
+ }
76
+ } catch (err) {
77
+ // ignore errors during shutdown
78
+ }
79
+ };
80
+
70
81
  module.exports = {
71
82
  initEventQueueRedisSubscribe,
72
83
  broadcastEvent,
84
+ closeSubscribeClient,
73
85
  };
package/src/runner.js CHANGED
@@ -9,22 +9,27 @@ const cdsHelper = require("./shared/cdsHelper");
9
9
  const distributedLock = require("./shared/distributedLock");
10
10
  const SetIntervalDriftSafe = require("./shared/SetIntervalDriftSafe");
11
11
  const { getSubdomainForTenantId } = require("./shared/cdsHelper");
12
+ const periodicEvents = require("./periodicEvents");
13
+ const { hashStringTo32Bit } = require("./shared/common");
12
14
 
13
15
  const COMPONENT_NAME = "eventQueue/runner";
14
16
  const EVENT_QUEUE_RUN_ID = "EVENT_QUEUE_RUN_ID";
15
17
  const EVENT_QUEUE_RUN_TS = "EVENT_QUEUE_RUN_TS";
18
+ const EVENT_QUEUE_RUN_PERIODIC_EVENT = "EVENT_QUEUE_RUN_PERIODIC_EVENT";
16
19
  const OFFSET_FIRST_RUN = 10 * 1000;
17
20
 
18
- const singleTenant = () => _scheduleFunction(_executeRunForTenant);
21
+ let tenantIdHash;
19
22
 
20
- const multiTenancyDb = () => _scheduleFunction(_multiTenancyDb);
23
+ const singleTenant = () => _scheduleFunction(_checkPeriodicEventsSingleTenant, _executeRunForTenant);
21
24
 
22
- const multiTenancyRedis = () => _scheduleFunction(_multiTenancyRedis);
25
+ const multiTenancyDb = () => _scheduleFunction(_multiTenancyPeriodicEvents, _multiTenancyDb);
23
26
 
24
- const _scheduleFunction = async (fn) => {
27
+ const multiTenancyRedis = () => _scheduleFunction(_multiTenancyPeriodicEvents, _multiTenancyRedis);
28
+
29
+ const _scheduleFunction = async (singleRunFn, periodicFn) => {
25
30
  const logger = cds.log(COMPONENT_NAME);
26
31
  const configInstance = eventQueueConfig.getConfigInstance();
27
- const eventsForAutomaticRun = configInstance.events;
32
+ const eventsForAutomaticRun = configInstance.allEvents;
28
33
  if (!eventsForAutomaticRun.length) {
29
34
  logger.warn("no events for automatic run are configured - skipping runner registration");
30
35
  return;
@@ -36,7 +41,7 @@ const _scheduleFunction = async (fn) => {
36
41
  logger.info("runner is deactivated via config variable. Skipping this run.");
37
42
  return;
38
43
  }
39
- return fn();
44
+ return periodicFn();
40
45
  };
41
46
 
42
47
  const offsetDependingOnLastRun = await _calculateOffsetForFirstRun();
@@ -46,6 +51,7 @@ const _scheduleFunction = async (fn) => {
46
51
  });
47
52
 
48
53
  setTimeout(() => {
54
+ singleRunFn();
49
55
  fnWithRunningCheck();
50
56
  const intervalRunner = new SetIntervalDriftSafe(configInstance.runInterval);
51
57
  intervalRunner.run(fnWithRunningCheck);
@@ -57,6 +63,8 @@ const _multiTenancyRedis = async () => {
57
63
  const emptyContext = new cds.EventContext({});
58
64
  logger.info("executing event queue run for multi instance and tenant");
59
65
  const tenantIds = await cdsHelper.getAllTenantIds();
66
+ _checkAndTriggerPriodicEventUpdate(tenantIds);
67
+
60
68
  const runId = await _acquireRunId(emptyContext);
61
69
 
62
70
  if (!runId) {
@@ -67,20 +75,21 @@ const _multiTenancyRedis = async () => {
67
75
  _executeAllTenants(tenantIds, runId);
68
76
  };
69
77
 
70
- const _multiTenancyDb = async () => {
71
- const logger = cds.log(COMPONENT_NAME);
72
- try {
73
- logger.info("executing event queue run for single instance and multi tenant");
74
- const tenantIds = await cdsHelper.getAllTenantIds();
75
- _executeAllTenants(tenantIds, EVENT_QUEUE_RUN_ID);
76
- } catch (err) {
77
- logger.error(
78
- `Couldn't fetch tenant ids for event queue processing! Next try after defined interval. Error: ${err}`
79
- );
78
+ const _checkAndTriggerPriodicEventUpdate = (tenantIds) => {
79
+ const hash = hashStringTo32Bit(JSON.stringify(tenantIds));
80
+ if (!tenantIdHash) {
81
+ tenantIdHash = hash;
82
+ return;
83
+ }
84
+ if (tenantIdHash && tenantIdHash !== hash) {
85
+ cds.log(COMPONENT_NAME).info("tenant id hash changed, triggering updating periodic events!");
86
+ _multiTenancyPeriodicEvents().catch((err) => {
87
+ cds.log(COMPONENT_NAME).error("Error during triggering updating periodic events! Error:", err);
88
+ });
80
89
  }
81
90
  };
82
91
 
83
- const _executeAllTenants = (tenantIds, runId) => {
92
+ const _executeAllTenantsGeneric = (tenantIds, runId, fn) => {
84
93
  const configInstance = eventQueueConfig.getConfigInstance();
85
94
  const workerQueueInstance = getWorkerPoolInstance();
86
95
  tenantIds.forEach((tenantId) => {
@@ -93,7 +102,7 @@ const _executeAllTenants = (tenantIds, runId) => {
93
102
  if (!couldAcquireLock) {
94
103
  return;
95
104
  }
96
- await _executeRunForTenant(tenantId, runId);
105
+ await fn(tenantId, runId);
97
106
  } catch (err) {
98
107
  cds.log(COMPONENT_NAME).error("executing event-queue run for tenant failed", {
99
108
  tenantId,
@@ -103,11 +112,16 @@ const _executeAllTenants = (tenantIds, runId) => {
103
112
  });
104
113
  };
105
114
 
115
+ const _executeAllTenants = (tenantIds, runId) => _executeAllTenantsGeneric(tenantIds, runId, _executeRunForTenant);
116
+
117
+ const _executePeriodicEventsAllTenants = (tenantIds, runId) =>
118
+ _executeAllTenantsGeneric(tenantIds, runId, _checkPeriodicEventsSingleTenant);
119
+
106
120
  const _executeRunForTenant = async (tenantId, runId) => {
107
121
  const logger = cds.log(COMPONENT_NAME);
108
122
  const configInstance = eventQueueConfig.getConfigInstance();
109
123
  try {
110
- const eventsForAutomaticRun = configInstance.events;
124
+ const eventsForAutomaticRun = configInstance.allEvents;
111
125
  const subdomain = await cdsHelper.getSubdomainForTenantId(tenantId);
112
126
  const context = new cds.EventContext({
113
127
  tenant: tenantId,
@@ -211,6 +225,60 @@ const runEventCombinationForTenant = async (tenantId, type, subType) => {
211
225
  }
212
226
  };
213
227
 
228
+ const _multiTenancyDb = async () => {
229
+ const logger = cds.log(COMPONENT_NAME);
230
+ try {
231
+ logger.info("executing event queue run for single instance and multi tenant");
232
+ const tenantIds = await cdsHelper.getAllTenantIds();
233
+ _checkAndTriggerPriodicEventUpdate(tenantIds);
234
+ _executeAllTenants(tenantIds, EVENT_QUEUE_RUN_ID);
235
+ } catch (err) {
236
+ logger.error(
237
+ `Couldn't fetch tenant ids for event queue processing! Next try after defined interval. Error: ${err}`
238
+ );
239
+ }
240
+ };
241
+
242
+ const _multiTenancyPeriodicEvents = async () => {
243
+ const logger = cds.log(COMPONENT_NAME);
244
+ try {
245
+ logger.info("executing event queue update periodic events");
246
+ const tenantIds = await cdsHelper.getAllTenantIds();
247
+ _executePeriodicEventsAllTenants(tenantIds, EVENT_QUEUE_RUN_PERIODIC_EVENT);
248
+ } catch (err) {
249
+ logger.error(`Couldn't fetch tenant ids for updating periodic event processing! Error: ${err}`);
250
+ }
251
+ };
252
+
253
+ const _checkPeriodicEventsSingleTenant = async (tenantId) => {
254
+ const logger = cds.log(COMPONENT_NAME);
255
+ const configInstance = eventQueueConfig.getConfigInstance();
256
+ if (!configInstance.updatePeriodicEvents) {
257
+ logger.info("updating of periodic events is disabled");
258
+ }
259
+ try {
260
+ const subdomain = await cdsHelper.getSubdomainForTenantId(tenantId);
261
+ const context = new cds.EventContext({
262
+ tenant: tenantId,
263
+ // NOTE: we need this because of logging otherwise logs would not contain the subdomain
264
+ http: { req: { authInfo: { getSubdomain: () => subdomain } } },
265
+ });
266
+ cds.context = context;
267
+ logger.info("executing updating periotic events", {
268
+ tenantId,
269
+ subdomain,
270
+ });
271
+ await cdsHelper.executeInNewTransaction(context, "update-periodic-events", async (tx) => {
272
+ await periodicEvents.checkAndInsertPeriodicEvents(tx.context);
273
+ });
274
+ } catch (err) {
275
+ logger.error(`Couldn't process eventQueue for tenant! Next try after defined interval. Error: ${err}`, {
276
+ tenantId,
277
+ redisEnabled: configInstance.redisEnabled,
278
+ });
279
+ }
280
+ };
281
+
214
282
  module.exports = {
215
283
  singleTenant,
216
284
  multiTenancyDb,
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ const crypto = require("crypto");
4
+
3
5
  const { floor, abs, min } = Math;
4
6
 
5
7
  const arrayToFlatMap = (array, key = "ID") => {
@@ -118,4 +120,16 @@ const isValidDate = (value) => {
118
120
  }
119
121
  };
120
122
 
121
- module.exports = { arrayToFlatMap, Funnel, limiter, isValidDate };
123
+ const processChunkedSync = (inputs, chunkSize, chunkHandler) => {
124
+ let start = 0;
125
+ while (start < inputs.length) {
126
+ let end = start + chunkSize > inputs.length ? inputs.length : start + chunkSize;
127
+ const chunk = inputs.slice(start, end);
128
+ chunkHandler(chunk);
129
+ start = end;
130
+ }
131
+ };
132
+
133
+ const hashStringTo32Bit = (value) => crypto.createHash("sha256").update(String(value)).digest("base64").slice(0, 32);
134
+
135
+ module.exports = { arrayToFlatMap, Funnel, limiter, isValidDate, processChunkedSync, hashStringTo32Bit };
@@ -3,8 +3,9 @@
3
3
  const cds = require("@sap/cds");
4
4
 
5
5
  const { broadcastEvent } = require("../redisPubSub");
6
+ const config = require("./../config");
6
7
 
7
- const COMPONENT_NAME = "eventQueue/shared/EventScheduler";
8
+ const COMPONENT_NAME = "eventQueue/shared/eventScheduler";
8
9
 
9
10
  let instance;
10
11
  class EventScheduler {
@@ -12,9 +13,10 @@ class EventScheduler {
12
13
  constructor() {}
13
14
 
14
15
  scheduleEvent(tenantId, type, subType, startAfter) {
15
- const startAfterSeconds = startAfter.getSeconds();
16
- const secondsUntilNextTen = 10 - (startAfterSeconds % 10);
17
- const roundUpDate = new Date(startAfter.getTime() + secondsUntilNextTen * 1000);
16
+ const configInstance = config.getConfigInstance();
17
+ const eventConfig = configInstance.getEventConfig(type, subType);
18
+ const scheduleWithoutDelay = configInstance.isPeriodicEvent(type, subType) && eventConfig.interval < 30 * 1000;
19
+ const roundUpDate = scheduleWithoutDelay ? startAfter : this.calculateFutureTime(startAfter, 10);
18
20
  const key = [tenantId, type, subType, roundUpDate.toISOString()].join("##");
19
21
  if (this.#scheduledEvents[key]) {
20
22
  return; // event combination already scheduled
@@ -38,6 +40,12 @@ class EventScheduler {
38
40
  }, roundUpDate.getTime() - Date.now()).unref();
39
41
  }
40
42
 
43
+ calculateFutureTime(date, seoncds) {
44
+ const startAfterSeconds = date.getSeconds();
45
+ const secondsUntil = seoncds - (startAfterSeconds % seoncds);
46
+ return new Date(date.getTime() + secondsUntil * 1000);
47
+ }
48
+
41
49
  clearScheduledEvents() {
42
50
  this.#scheduledEvents = {};
43
51
  }
@@ -29,9 +29,18 @@ const _createClientBase = () => {
29
29
  if (env.isOnCF) {
30
30
  try {
31
31
  const credentials = env.getRedisCredentialsFromEnv();
32
- // NOTE: settings the user explicitly to empty resolves auth problems, see
33
- // https://github.com/go-redis/redis/issues/1343
32
+ const redisIsCluster = credentials.cluster_mode;
34
33
  const url = credentials.uri.replace(/(?<=rediss:\/\/)[\w-]+?(?=:)/, "");
34
+ if (redisIsCluster) {
35
+ return redis.createCluster({
36
+ rootNodes: [{ url }],
37
+ // https://github.com/redis/node-redis/issues/1782
38
+ defaults: {
39
+ password: credentials.password,
40
+ socket: { tls: credentials.tls },
41
+ },
42
+ });
43
+ }
35
44
  return redis.createClient({ url });
36
45
  } catch (err) {
37
46
  throw EventQueueError.redisConnectionFailure(err);
@@ -87,9 +96,21 @@ const publishMessage = async (channel, message) => {
87
96
 
88
97
  const _localReconnectStrategy = () => EventQueueError.redisNoReconnect();
89
98
 
99
+ const closeMainClient = async () => {
100
+ try {
101
+ const client = await mainClientPromise;
102
+ if (client?.quit) {
103
+ await client.quit();
104
+ }
105
+ } catch (err) {
106
+ // ignore errors during shutdown
107
+ }
108
+ };
109
+
90
110
  module.exports = {
91
111
  createClientAndConnect,
92
112
  createMainClientAndConnect,
93
113
  subscribeRedisChannel,
94
114
  publishMessage,
115
+ closeMainClient,
95
116
  };