@cap-js-community/event-queue 1.5.3 → 1.6.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": "1.5.3",
3
+ "version": "1.6.1",
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,16 +43,16 @@
43
43
  "node": ">=18"
44
44
  },
45
45
  "dependencies": {
46
- "@sap/xssec": "^3.6.1",
47
- "redis": "^4.6.14",
46
+ "@sap/xssec": "^4.2.1",
47
+ "redis": "^4.7.0",
48
48
  "verror": "^1.10.1",
49
- "yaml": "^2.4.2"
49
+ "yaml": "^2.5.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@cap-js/hana": "^1.0.0",
53
- "@cap-js/sqlite": "^1.7.2",
54
- "@sap/cds": "^7.9.2",
55
- "@sap/cds-dk": "^7.8.0",
52
+ "@cap-js/hana": "^1.1.1",
53
+ "@cap-js/sqlite": "^1.7.3",
54
+ "@sap/cds": "^8.1.0",
55
+ "@sap/cds-dk": "^8.1.0",
56
56
  "eslint": "^8.57.0",
57
57
  "eslint-config-prettier": "^9.1.0",
58
58
  "eslint-plugin-jest": "^28.6.0",
@@ -18,8 +18,7 @@ const ERROR_CODES = {
18
18
  NO_MANUEL_INSERT_OF_PERIODIC: "NO_MANUEL_INSERT_OF_PERIODIC",
19
19
  LOAD_HIGHER_THAN_LIMIT: "LOAD_HIGHER_THAN_LIMIT",
20
20
  NOT_ALLOWED_PRIORITY: "NOT_ALLOWED_PRIORITY",
21
- SCHEMA_TENANT_MISMATCH: "SCHEMA_TENANT_MISMATCH",
22
- GLOBAL_CDS_CONTEXT_MISMATCH: "GLOBAL_CDS_CONTEXT_MISMATCH",
21
+ APP_NAMES_FORMAT: "APP_NAMES_FORMAT",
23
22
  };
24
23
 
25
24
  const ERROR_CODES_META = {
@@ -69,11 +68,8 @@ const ERROR_CODES_META = {
69
68
  [ERROR_CODES.NOT_ALLOWED_PRIORITY]: {
70
69
  message: "The supplied priority is not allowed. Only LOW, MEDIUM, HIGH is allowed!",
71
70
  },
72
- [ERROR_CODES.SCHEMA_TENANT_MISMATCH]: {
73
- message: "The db client associated to the tenant context does not match! Processing will be skipped.",
74
- },
75
- [ERROR_CODES.GLOBAL_CDS_CONTEXT_MISMATCH]: {
76
- message: "The global cds context does not match the local cds context.",
71
+ [ERROR_CODES.APP_NAMES_FORMAT]: {
72
+ message: "The app names property must be an array and only contain strings.",
77
73
  },
78
74
  };
79
75
 
@@ -244,23 +240,12 @@ class EventQueueError extends VError {
244
240
  );
245
241
  }
246
242
 
247
- static dbClientSchemaMismatch(tenantId, dbClientSchema, serviceManagerSchema) {
248
- const { message } = ERROR_CODES_META[ERROR_CODES.SCHEMA_TENANT_MISMATCH];
249
- return new EventQueueError(
250
- {
251
- name: ERROR_CODES.SCHEMA_TENANT_MISMATCH,
252
- info: { tenantId, dbClientSchema, serviceManagerSchema },
253
- },
254
- message
255
- );
256
- }
257
-
258
- static globalCdsContextNotMatchingLocal(globalProperties, localProperties) {
259
- const { message } = ERROR_CODES_META[ERROR_CODES.GLOBAL_CDS_CONTEXT_MISMATCH];
243
+ static appNamesFormat(type, subType, appNames) {
244
+ const { message } = ERROR_CODES_META[ERROR_CODES.APP_NAMES_FORMAT];
260
245
  return new EventQueueError(
261
246
  {
262
- name: ERROR_CODES.GLOBAL_CDS_CONTEXT_MISMATCH,
263
- info: { globalProperties, localProperties },
247
+ name: ERROR_CODES.APP_NAMES_FORMAT,
248
+ info: { type, subType, appNames },
264
249
  },
265
250
  message
266
251
  );
@@ -163,12 +163,13 @@ class EventQueueProcessorBase {
163
163
  );
164
164
  }
165
165
 
166
- logTimeExceeded(iterationCounter) {
167
- this.logger.info("Exiting event queue processing as max time exceeded", {
166
+ logTimeExceededAndPublishContinue(iterationCounter) {
167
+ this.logger.info("Exiting event queue processing as max time exceeded - but broadcast to trigger processing", {
168
168
  eventType: this.#eventType,
169
169
  eventSubType: this.#eventSubType,
170
170
  iterationCounter,
171
171
  });
172
+ this.#eventSchedulerInstance.scheduleEvent(this.__context.tenant, this.#eventType, this.#eventSubType, new Date());
172
173
  }
173
174
 
174
175
  logStartMessage() {
@@ -1108,6 +1109,10 @@ class EventQueueProcessorBase {
1108
1109
  return this.#eventType.replace(SUFFIX_PERIODIC, "");
1109
1110
  }
1110
1111
 
1112
+ get rawEventType() {
1113
+ return this.#eventType;
1114
+ }
1115
+
1111
1116
  get eventSubType() {
1112
1117
  return this.#eventSubType;
1113
1118
  }
package/src/config.js CHANGED
@@ -105,7 +105,12 @@ class Config {
105
105
  }
106
106
 
107
107
  _checkRedisIsBound() {
108
- return !!this.#env.getRedisCredentialsFromEnv();
108
+ return !!this.#env.redisCredentialsFromEnv;
109
+ }
110
+
111
+ shouldBeProcessedInThisApplication(type, subType) {
112
+ const config = this.#eventMap[this.generateKey(type, subType)];
113
+ return !config._appNameMap || config._appNameMap[this.#env.applicationName];
109
114
  }
110
115
 
111
116
  checkRedisEnabled() {
@@ -276,8 +281,12 @@ class Config {
276
281
  eventOutdatedCheck: config.eventOutdatedCheck,
277
282
  checkForNextChunk: config.checkForNextChunk,
278
283
  deleteFinishedEventsAfterDays: config.deleteFinishedEventsAfterDays,
284
+ appNames: config.appNames,
279
285
  internalEvent: true,
280
286
  };
287
+ eventConfig._appNameMap = eventConfig.appNames
288
+ ? Object.fromEntries(new Map(eventConfig.appNames.map((a) => [a, true])))
289
+ : null;
281
290
  this.#config.events.push(eventConfig);
282
291
  this.#eventMap[this.generateKey(CAP_EVENT_TYPE, serviceName)] = eventConfig;
283
292
  }
@@ -317,6 +326,7 @@ class Config {
317
326
  event.load = event.load ?? DEFAULT_LOAD;
318
327
  event.priority = event.priority ?? DEFAULT_PRIORITY;
319
328
  this.validateAdHocEvents(result, event);
329
+ event._appNameMap = event.appNames ? Object.fromEntries(new Map(event.appNames.map((a) => [a, true]))) : null;
320
330
  result[this.generateKey(event.type, event.subType)] = event;
321
331
  return result;
322
332
  }, {});
@@ -326,6 +336,7 @@ class Config {
326
336
  event.type = `${event.type}${SUFFIX_PERIODIC}`;
327
337
  event.isPeriodic = true;
328
338
  this.validatePeriodicConfig(result, event);
339
+ event._appNameMap = event.appNames ? Object.fromEntries(new Map(event.appNames.map((a) => [a, true]))) : null;
329
340
  result[this.generateKey(event.type, event.subType)] = event;
330
341
  return result;
331
342
  }, this.#eventMap);
@@ -355,6 +366,12 @@ class Config {
355
366
  if (!config.impl) {
356
367
  throw EventQueueError.missingImpl(config.type, config.subType);
357
368
  }
369
+
370
+ if (config.appNames) {
371
+ if (!Array.isArray(config.appNames) || config.appNames.some((appName) => typeof appName !== "string")) {
372
+ throw EventQueueError.appNamesFormat(config.type, config.subType, config.appNames);
373
+ }
374
+ }
358
375
  }
359
376
 
360
377
  generateKey(type, subType) {
package/src/index.d.ts CHANGED
@@ -117,6 +117,7 @@ export declare class EventQueueProcessorBase {
117
117
  setShouldRollbackTransaction(key: string): void;
118
118
  shouldRollbackTransaction(key: string): boolean;
119
119
  beforeProcessingEvents(): Promise<void>;
120
+ addEntryToProcessingMap(key: string, queueEntry: EventEntity, payload: Object): void;
120
121
 
121
122
  set logger(value: CdsLogger);
122
123
  get logger(): CdsLogger;
@@ -214,3 +215,38 @@ declare class Config {
214
215
  }
215
216
 
216
217
  export const config: Config;
218
+
219
+ export const workerQueue: WorkerQueue;
220
+
221
+ declare class WorkerQueue {
222
+ constructor(concurrency: number);
223
+
224
+ addToQueue(load: number, label: string, priority?: Priorities, cb?: () => any): Promise<any>;
225
+
226
+ _executeFunction(
227
+ load: number,
228
+ label: string,
229
+ cb: () => any,
230
+ resolve: (value?: unknown) => void,
231
+ reject: (reason?: any) => void,
232
+ startTime: bigint,
233
+ priority: string
234
+ ): void;
235
+
236
+ get runningPromises(): Array<Promise<any>>;
237
+ get runningLoad(): number;
238
+
239
+ static get instance(): WorkerQueue;
240
+
241
+ get queue(): Record<
242
+ string,
243
+ Array<[number, string, () => any, (value?: unknown) => void, (reason?: any) => void, bigint]>
244
+ >;
245
+ }
246
+
247
+ interface Priorities {
248
+ Low: string;
249
+ Medium: string;
250
+ High: string;
251
+ VeryHigh: string;
252
+ }
package/src/index.js CHANGED
@@ -10,4 +10,5 @@ module.exports = {
10
10
  ...require("./constants"),
11
11
  ...require("./publishEvent"),
12
12
  EventQueueProcessorBase: require("./EventQueueProcessorBase"),
13
+ WorkerQueue: require("./shared/WorkerQueue"),
13
14
  };
@@ -15,9 +15,19 @@ const COMPONENT_NAME = "/eventQueue/eventQueueAsOutbox";
15
15
  function outboxed(srv, customOpts) {
16
16
  // outbox max. once
17
17
  const logger = cds.log(COMPONENT_NAME);
18
+ const outboxOpts = Object.assign(
19
+ {},
20
+ (typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
21
+ (typeof srv.options?.outbox === "object" && srv.options.outbox) || {},
22
+ customOpts || {}
23
+ );
24
+
18
25
  if (!new.target) {
19
26
  const former = srv[OUTBOXED];
20
27
  if (former) {
28
+ if (outboxOpts.kind === "persistent-outbox") {
29
+ config.addCAPOutboxEvent(srv.name, outboxOpts);
30
+ }
21
31
  return former;
22
32
  }
23
33
  }
@@ -30,14 +40,9 @@ function outboxed(srv, customOpts) {
30
40
  Object.defineProperty(srv, OUTBOXED, { value: outboxedSrv });
31
41
  }
32
42
 
33
- const outboxOpts = Object.assign(
34
- {},
35
- (typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
36
- (typeof srv.options?.outbox === "object" && srv.options.outbox) || {},
37
- customOpts || {}
38
- );
39
-
40
- config.addCAPOutboxEvent(srv.name, outboxOpts);
43
+ if (outboxOpts.kind === "persistent-outbox") {
44
+ config.addCAPOutboxEvent(srv.name, outboxOpts);
45
+ }
41
46
  outboxedSrv.handle = async function (req) {
42
47
  const context = req.context || cds.context;
43
48
  if (outboxOpts.kind === "persistent-outbox") {
@@ -120,7 +120,7 @@ const reevaluateShouldContinue = (eventTypeInstance, iterationCounter, startTime
120
120
  return true;
121
121
  }
122
122
 
123
- eventTypeInstance.logTimeExceeded(iterationCounter);
123
+ eventTypeInstance.logTimeExceededAndPublishContinue(iterationCounter);
124
124
  return false;
125
125
  };
126
126
 
@@ -269,6 +269,7 @@ const _checkEventIsBlocked = async (baseInstance) => {
269
269
  });
270
270
  }
271
271
  } else {
272
+ // TODO: we should be able to get rid of baseInstance.isPeriodicEvent with rawEventType
272
273
  eventBlocked = config.isEventBlocked(
273
274
  baseInstance.eventType,
274
275
  baseInstance.eventSubType,
@@ -283,11 +284,16 @@ const _checkEventIsBlocked = async (baseInstance) => {
283
284
 
284
285
  if (eventBlocked) {
285
286
  baseInstance.logger.info("skipping run because event is blocked by configuration", {
286
- type: baseInstance.eventType,
287
+ type: baseInstance.rawEventType,
287
288
  subType: baseInstance.eventSubType,
288
289
  tenantUnsubscribed: config.isTenantUnsubscribed(baseInstance.context.tenant),
289
290
  });
290
291
  }
292
+
293
+ if (!eventBlocked) {
294
+ eventBlocked = !config.shouldBeProcessedInThisApplication(baseInstance.rawEventType, baseInstance.eventSubType);
295
+ }
296
+
291
297
  return eventBlocked;
292
298
  };
293
299
 
@@ -35,14 +35,6 @@ const _messageHandlerProcessEvents = async (messageData) => {
35
35
  return;
36
36
  }
37
37
 
38
- const user = await cds.tx({ tenant: tenantId }, async () => {
39
- return new cds.User.Privileged({ id: config.userId, authInfo: await common.getAuthInfo(tenantId) });
40
- });
41
- const tenantContext = {
42
- tenant: tenantId,
43
- user,
44
- };
45
-
46
38
  if (!config.getEventConfig(type, subType)) {
47
39
  if (config.isCapOutboxEvent(type)) {
48
40
  try {
@@ -64,6 +56,23 @@ const _messageHandlerProcessEvents = async (messageData) => {
64
56
  }
65
57
  }
66
58
 
59
+ if (!(config.getEventConfig(type, subType) && config.shouldBeProcessedInThisApplication(type, subType))) {
60
+ logger.debug("event is not configured to be processed on this app-name", {
61
+ tenantId,
62
+ type,
63
+ subType,
64
+ });
65
+ return;
66
+ }
67
+
68
+ const user = await cds.tx({ tenant: tenantId }, async () => {
69
+ return new cds.User.Privileged({ id: config.userId, authInfo: await common.getAuthInfo(tenantId) });
70
+ });
71
+ const tenantContext = {
72
+ tenant: tenantId,
73
+ user,
74
+ };
75
+
67
76
  return await cds.tx(tenantContext, async ({ context }) => {
68
77
  return await runnerHelper.runEventCombinationForTenant(context, type, subType, { lockId, shouldTrace: true });
69
78
  });
@@ -29,18 +29,21 @@ const getOpenQueueEntries = async (tx) => {
29
29
 
30
30
  const result = [];
31
31
  for (const { type, subType } of entries) {
32
- if (type.startsWith("CAP_OUTBOX")) {
33
- if (cds.requires[subType]) {
34
- await cds.connect.to(subType).catch(() => {});
35
- result.push({ type, subType });
36
- } else {
37
- const service = await cds.connect.to(subType).catch(() => {});
38
- if (service) {
39
- result.push({ type, subType });
40
- }
41
- }
32
+ if (eventConfig.isCapOutboxEvent(type)) {
33
+ await cds.connect
34
+ .to(subType)
35
+ .then((service) => {
36
+ if (!service) {
37
+ return;
38
+ }
39
+ cds.outboxed(service);
40
+ if (eventConfig.shouldBeProcessedInThisApplication(type, subType)) {
41
+ result.push({ type, subType });
42
+ }
43
+ })
44
+ .catch(() => {});
42
45
  } else {
43
- if (eventConfig.getEventConfig(type, subType)) {
46
+ if (eventConfig.getEventConfig(type, subType) && eventConfig.shouldBeProcessedInThisApplication(type, subType)) {
44
47
  result.push({ type, subType });
45
48
  }
46
49
  }
@@ -8,7 +8,7 @@ const { Priorities } = require("../constants");
8
8
  const SetIntervalDriftSafe = require("./SetIntervalDriftSafe");
9
9
 
10
10
  const PRIORITIES = Object.values(Priorities).reverse();
11
- const PRIORITY_MULTIPLICATOR = PRIORITIES.reduce((result, element, index) => {
11
+ const PRIORITY_MULTIPLICATION = PRIORITIES.reduce((result, element, index) => {
12
12
  result[element] = index + 1;
13
13
  return result;
14
14
  }, {});
@@ -152,6 +152,10 @@ class WorkerQueue {
152
152
  return this.#queue;
153
153
  }
154
154
 
155
+ get runningLoad() {
156
+ return this.#runningLoad;
157
+ }
158
+
155
159
  #checkAndLogWaitingTime(startTime, label, priority) {
156
160
  const ts = Date.now();
157
161
  if (ts - lastLogTs <= 1000) {
@@ -159,7 +163,7 @@ class WorkerQueue {
159
163
  }
160
164
  lastLogTs = ts;
161
165
  const diffMs = Math.round(Number(process.hrtime.bigint() - startTime) / NANO_TO_MS);
162
- const priorityMultiplication = PRIORITY_MULTIPLICATOR[priority];
166
+ const priorityMultiplication = PRIORITY_MULTIPLICATION[priority];
163
167
  let logLevel;
164
168
  if (diffMs >= THRESHOLD.ERROR * priorityMultiplication) {
165
169
  logLevel = "error";
@@ -1,15 +1,10 @@
1
1
  "use strict";
2
2
 
3
3
  const crypto = require("crypto");
4
- const { promisify } = require("util");
5
4
 
6
5
  const cds = require("@sap/cds");
7
6
  const xssec = require("@sap/xssec");
8
7
 
9
- const getAuthTokenAsync = promisify(xssec.requests.requestClientCredentialsToken);
10
- const getCreateSecurityContextAsync = promisify(xssec.createSecurityContext);
11
-
12
- let authInfoCache = {};
13
8
  const MARGIN_AUTH_INFO_EXPIRY = 60 * 1000;
14
9
  const COMPONENT_NAME = "/eventQueue/common";
15
10
 
@@ -74,9 +69,15 @@ const processChunkedSync = (inputs, chunkSize, chunkHandler) => {
74
69
  const hashStringTo32Bit = (value) => crypto.createHash("sha256").update(String(value)).digest("base64").slice(0, 32);
75
70
 
76
71
  const _getNewAuthInfo = async (tenantId) => {
72
+ const authInfoCache = getAuthInfo._authInfoCache;
73
+ authInfoCache[tenantId] = authInfoCache[tenantId] ?? {};
77
74
  try {
78
- const token = await getAuthTokenAsync(null, cds.requires.auth.credentials, null, tenantId);
79
- const authInfo = await getCreateSecurityContextAsync(token, cds.requires.auth.credentials);
75
+ if (!_getNewAuthInfo._xsuaaService) {
76
+ _getNewAuthInfo._xsuaaService = new xssec.XsuaaService(cds.requires.auth.credentials);
77
+ }
78
+ const authService = _getNewAuthInfo._xsuaaService;
79
+ const token = await authService.fetchClientCredentialsToken();
80
+ const authInfo = await authService.createSecurityContext(token.access_token);
80
81
  authInfoCache[tenantId].expireTs = authInfo.getExpirationDate().getTime() - MARGIN_AUTH_INFO_EXPIRY;
81
82
  return authInfo;
82
83
  } catch (err) {
@@ -89,7 +90,13 @@ const getAuthInfo = async (tenantId) => {
89
90
  if (!cds.requires?.auth?.credentials) {
90
91
  return null; // no credentials not authInfo
91
92
  }
93
+ if (!cds.requires?.auth.kind.match(/jwt|xsuaa/i)) {
94
+ cds.log(COMPONENT_NAME).warn("Only 'jwt' or 'xsuaa' are supported as values for auth.kind.");
95
+ return null;
96
+ }
92
97
 
98
+ getAuthInfo._authInfoCache = getAuthInfo._authInfoCache ?? {};
99
+ const authInfoCache = getAuthInfo._authInfoCache;
93
100
  // not existing or existing but expired
94
101
  if (
95
102
  !authInfoCache[tenantId] ||
@@ -110,6 +117,6 @@ module.exports = {
110
117
  hashStringTo32Bit,
111
118
  getAuthInfo,
112
119
  __: {
113
- clearAuthInfoCache: () => (authInfoCache = {}),
120
+ clearAuthInfoCache: () => (getAuthInfo._authInfoCache = {}),
114
121
  },
115
122
  };
package/src/shared/env.js CHANGED
@@ -3,34 +3,42 @@
3
3
  let instance;
4
4
 
5
5
  class Env {
6
- #isLocal;
7
6
  #vcapServices;
7
+ #vcapApplication;
8
8
 
9
9
  constructor() {
10
10
  try {
11
11
  this.#vcapServices = JSON.parse(process.env.VCAP_SERVICES);
12
+ this.#vcapApplication = JSON.parse(process.env.VCAP_APPLICATION);
12
13
  } catch {
13
14
  this.#vcapServices = {};
15
+ this.#vcapApplication = {};
14
16
  }
15
17
  }
16
18
 
17
- getRedisCredentialsFromEnv() {
19
+ get redisCredentialsFromEnv() {
18
20
  return this.#vcapServices["redis-cache"]?.[0]?.credentials;
19
21
  }
20
22
 
21
- set isLocal(value) {
22
- this.#isLocal = value;
23
- }
24
- get isLocal() {
25
- return this.#isLocal;
23
+ get applicationName() {
24
+ return this.#vcapApplication.application_name;
26
25
  }
27
26
 
28
27
  set vcapServices(value) {
29
28
  this.#vcapServices = value;
30
29
  }
30
+
31
31
  get vcapServices() {
32
32
  return this.#vcapServices;
33
33
  }
34
+
35
+ set vcapApplication(value) {
36
+ this.#vcapApplication = value;
37
+ }
38
+
39
+ get vcapApplication() {
40
+ return this.#vcapApplication;
41
+ }
34
42
  }
35
43
 
36
44
  module.exports = {
@@ -47,7 +47,7 @@ class EventScheduler {
47
47
  }
48
48
 
49
49
  clearForTenant(tenantId) {
50
- Object.values(this.#eventsByTenants[tenantId]).forEach((timeoutId) => clearTimeout(timeoutId));
50
+ Object.keys(this.#eventsByTenants[tenantId] ?? []).forEach((timeoutId) => clearTimeout(timeoutId));
51
51
  }
52
52
 
53
53
  calculateOffset(type, subType, startAfter) {
@@ -67,6 +67,14 @@ class EventScheduler {
67
67
  clearScheduledEvents() {
68
68
  this.#scheduledEvents = {};
69
69
  }
70
+
71
+ clearEventsByTenants() {
72
+ this.#eventsByTenants = {};
73
+ }
74
+
75
+ get eventsByTenants() {
76
+ return this.#eventsByTenants;
77
+ }
70
78
  }
71
79
 
72
80
  module.exports = {
@@ -30,7 +30,7 @@ const createMainClientAndConnect = (options) => {
30
30
  const _createClientBase = (redisOptions) => {
31
31
  const env = getEnvInstance();
32
32
  try {
33
- const credentials = env.getRedisCredentialsFromEnv();
33
+ const credentials = env.redisCredentialsFromEnv;
34
34
  const redisIsCluster = credentials.cluster_mode;
35
35
  const url = credentials.uri.replace(/(?<=rediss:\/\/)[\w-]+?(?=:)/, "");
36
36
  if (redisIsCluster) {