@cap-js-community/event-queue 2.1.0 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "An event queue that enables secure transactional processing of asynchronous and periodic events, featuring instant event processing with Redis Pub/Sub and load distribution across all application instances.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -41,30 +41,30 @@
41
41
  "upgrade-lock": "npx shx rm -rf package-lock.json node_modules && npm i --package-lock"
42
42
  },
43
43
  "engines": {
44
- "node": ">=20"
44
+ "node": "^20.19.0 || ^22.13.0 || >=24"
45
45
  },
46
46
  "dependencies": {
47
47
  "@cap-js-community/common": "^0.4.0",
48
48
  "@sap/xssec": "^4.13.0",
49
49
  "cron-parser": "^5.5.0",
50
50
  "verror": "^1.10.1",
51
- "yaml": "^2.8.3"
51
+ "yaml": "^2.8.4"
52
52
  },
53
53
  "devDependencies": {
54
- "@actions/core": "^2.0.2",
55
- "@cap-js/cds-test": "^0.4.1",
56
- "@cap-js/db-service": "^2.8.2",
57
- "@cap-js/hana": "^2.6.0",
58
- "@cap-js/sqlite": "^2.1.3",
54
+ "@cap-js/cds-test": "^1.0.1",
55
+ "@cap-js/db-service": "^2.11.0",
56
+ "@cap-js/hana": "^2.8.0",
57
+ "@cap-js/sqlite": "^2.4.0",
58
+ "@eslint/js": "^10.0.1",
59
59
  "@opentelemetry/api": "^1.9.1",
60
- "@sap/cds": "^9.8.4",
61
- "@sap/cds-dk": "^9.8.2",
62
- "eslint": "^8.57.1",
60
+ "@sap/cds": "^9.9.1",
61
+ "@sap/cds-dk": "^9.9.0",
62
+ "eslint": "^10.3.0",
63
63
  "eslint-config-prettier": "^10.1.8",
64
- "eslint-plugin-jest": "^29.12.1",
65
- "eslint-plugin-node": "^11.1.0",
66
- "jest": "^29.7.0",
67
- "prettier": "^2.8.8"
64
+ "eslint-plugin-jest": "^29.15.2",
65
+ "globals": "^17.6.0",
66
+ "jest": "^30.3.0",
67
+ "prettier": "^3.8.3"
68
68
  },
69
69
  "homepage": "https://cap-js-community.github.io/event-queue/",
70
70
  "repository": {
@@ -28,6 +28,7 @@ const ERROR_CODES = {
28
28
  APP_INSTANCES_FORMAT: "APP_INSTANCES_FORMAT",
29
29
  MULTI_INSTANCE_PROCESSING_NOT_ALLOWED: "MULTI_INSTANCE_PROCESSING_NOT_ALLOWED",
30
30
  INVALID_CLUSTER_HANDLER_RESULT: "INVALID_CLUSTER_HANDLER_RESULT",
31
+ INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT: "INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT",
31
32
  };
32
33
 
33
34
  const ERROR_CODES_META = {
@@ -107,6 +108,9 @@ const ERROR_CODES_META = {
107
108
  [ERROR_CODES.MULTI_INSTANCE_PROCESSING_NOT_ALLOWED]: {
108
109
  message: "The config multiInstanceProcessing is currently only allowed for ad-hoc events and single-tenant-apps.",
109
110
  },
111
+ [ERROR_CODES.INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT]: {
112
+ message: "authCacheExpiryReductionMaxPercent must be a number between 0 and 80.",
113
+ },
110
114
  };
111
115
 
112
116
  class EventQueueError extends VError {
@@ -375,6 +379,17 @@ class EventQueueError extends VError {
375
379
  );
376
380
  }
377
381
 
382
+ static invalidAuthCacheExpiryReductionMaxPercent(value) {
383
+ const { message } = ERROR_CODES_META[ERROR_CODES.INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT];
384
+ return new EventQueueError(
385
+ {
386
+ name: ERROR_CODES.INVALID_AUTH_CACHE_EXPIRY_REDUCTION_MAX_PERCENT,
387
+ info: { value },
388
+ },
389
+ message
390
+ );
391
+ }
392
+
378
393
  static invalidClusterHandlerResult(clusterKey, propertyName) {
379
394
  const { message } = ERROR_CODES_META[ERROR_CODES.INVALID_CLUSTER_HANDLER_RESULT];
380
395
  return new EventQueueError(
@@ -69,7 +69,7 @@ class EventQueueProcessorBase {
69
69
  this.__parallelEventProcessing = LIMIT_PARALLEL_EVENT_PROCESSING;
70
70
  }
71
71
  this.__concurrentEventProcessing = this.#eventConfig.multiInstanceProcessing;
72
- this.__retryAttempts = this.#isPeriodic ? 1 : this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS;
72
+ this.__retryAttempts = this.#isPeriodic ? 1 : (this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS);
73
73
  this.__selectMaxChunkSize = this.#eventConfig.selectMaxChunkSize ?? SELECT_LIMIT_EVENTS_PER_TICK;
74
74
  this.__selectNextChunk = !!this.#eventConfig.checkForNextChunk;
75
75
  this.__transactionMode = this.#eventConfig.transactionMode ?? TransactionMode.isolated;
@@ -457,7 +457,8 @@ class EventQueueProcessorBase {
457
457
  eventSubType: this.#eventSubType,
458
458
  statusMap,
459
459
  });
460
- const ts = new Date().toISOString();
460
+ const tsMs = Date.now();
461
+ const ts = new Date(tsMs).toISOString();
461
462
  const updateData = Object.entries(statusMap).reduce((result, [id, data]) => {
462
463
  const key = this.allowedFieldsEventHandler
463
464
  .map((name) => [name, data[name]])
@@ -503,7 +504,7 @@ class EventQueueProcessorBase {
503
504
 
504
505
  if (!data.startAfter && [EventProcessingStatus.Error, EventProcessingStatus.Open].includes(data.status)) {
505
506
  data.startAfter = new Date(
506
- Date.now() +
507
+ tsMs +
507
508
  (data.status === EventProcessingStatus.Error
508
509
  ? this.#eventConfig.retryFailedAfter
509
510
  : this.#eventConfig.retryOpenAfter)
@@ -954,7 +955,7 @@ class EventQueueProcessorBase {
954
955
  * the function 'addEventWithPayloadForProcessing'. This function is called after the clustering and before the
955
956
  * process-events-steps. The event data is available with this.eventProcessingMap.
956
957
  */
957
- // eslint-disable-next-line no-unused-vars
958
+
958
959
  async beforeProcessingEvents() {}
959
960
 
960
961
  async isOutdatedAndKeepAlive() {
@@ -1109,13 +1110,15 @@ class EventQueueProcessorBase {
1109
1110
  }
1110
1111
  }
1111
1112
 
1112
- #calculateCronDates() {
1113
+ #calculateCronDates(queueEntry) {
1113
1114
  if (!this.#eventConfig.cron) {
1114
1115
  return null;
1115
1116
  }
1116
1117
 
1117
- // NOTE: do not pass current date as we always want to calc. a future date
1118
+ // NOTE: base the calculation on the planned startAfter if the event was picked up early; otherwise on now to always calc. a future date
1119
+ const currentDate = new Date(Math.max(Date.now(), new Date(queueEntry.startAfter).getTime()));
1118
1120
  const cronExpression = CronExpressionParser.parse(this.#eventConfig.cron, {
1121
+ currentDate,
1119
1122
  tz: this.#eventConfig.tz,
1120
1123
  });
1121
1124
  return cronExpression.next();
@@ -1123,7 +1126,7 @@ class EventQueueProcessorBase {
1123
1126
 
1124
1127
  async scheduleNextPeriodEvent(queueEntry) {
1125
1128
  const intervalInMs = this.#eventConfig.cron ? null : this.#eventConfig.interval * 1000;
1126
- const next = this.#calculateCronDates();
1129
+ const next = this.#calculateCronDates(queueEntry);
1127
1130
  let newStartAfter;
1128
1131
 
1129
1132
  if (this.#eventConfig.cron) {
@@ -1166,7 +1169,8 @@ class EventQueueProcessorBase {
1166
1169
  })
1167
1170
  );
1168
1171
  this.tx._skipEventQueueBroadcast = false;
1169
- if (intervalInMs < this.#config.runInterval * 1.5) {
1172
+ const msUntilNextOccurrence = intervalInMs ?? newEvent.startAfter.getTime() - Date.now();
1173
+ if (msUntilNextOccurrence < this.#config.runInterval * 1.5) {
1170
1174
  this.#handleDelayedEvents([newEvent], { skipExcludeDelayedEventIds: true });
1171
1175
  const { relative: relativeAfterSchedule } = this.#eventSchedulerInstance.calculateOffset(
1172
1176
  this.#eventType,
package/src/config.js CHANGED
@@ -90,8 +90,6 @@ class Config {
90
90
  #redisEnabled;
91
91
  #initialized;
92
92
  #instanceLoadLimit;
93
- #tableNameEventQueue;
94
- #tableNameEventLock;
95
93
  #isEventQueueActive;
96
94
  #configFilePath;
97
95
  #processEventsAfterPublish;
@@ -122,6 +120,7 @@ class Config {
122
120
  #disableProcessingOfSuspendedTenants;
123
121
  #namespace;
124
122
  #processingNamespaces;
123
+ #authCacheExpiryReductionMaxPercent;
125
124
  static #instance;
126
125
  constructor() {
127
126
  this.#logger = cds.log(COMPONENT_NAME);
@@ -132,12 +131,11 @@ class Config {
132
131
  this.#redisEnabled = null;
133
132
  this.#initialized = false;
134
133
  this.#instanceLoadLimit = 100;
135
- this.#tableNameEventQueue = null;
136
- this.#tableNameEventLock = null;
137
134
  this.#isEventQueueActive = true;
138
135
  this.#configFilePath = null;
139
136
  this.#processEventsAfterPublish = null;
140
137
  this.#disableRedis = null;
138
+ this.#authCacheExpiryReductionMaxPercent = 10;
141
139
  this.#env = getEnvInstance();
142
140
  }
143
141
 
@@ -337,6 +335,7 @@ class Config {
337
335
  (typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
338
336
  (typeof cds.requires.queue === "object" && cds.requires.queue) || {},
339
337
  (typeof cds.env.requires[serviceName]?.outbox === "object" && cds.env.requires[serviceName].outbox) || {},
338
+ (typeof cds.env.requires[serviceName]?.outboxed === "object" && cds.env.requires[serviceName].outboxed) || {},
340
339
  (typeof cds.env.requires[serviceName]?.queued === "object" && cds.env.requires[serviceName].queued) || {}
341
340
  );
342
341
  }
@@ -799,6 +798,17 @@ class Config {
799
798
  return this.#configFilePath;
800
799
  }
801
800
 
801
+ set authCacheExpiryReductionMaxPercent(value) {
802
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 80) {
803
+ throw EventQueueError.invalidAuthCacheExpiryReductionMaxPercent(value);
804
+ }
805
+ this.#authCacheExpiryReductionMaxPercent = value;
806
+ }
807
+
808
+ get authCacheExpiryReductionMaxPercent() {
809
+ return this.#authCacheExpiryReductionMaxPercent;
810
+ }
811
+
802
812
  set processEventsAfterPublish(value) {
803
813
  this.#processEventsAfterPublish = value;
804
814
  }
package/src/initialize.js CHANGED
@@ -51,6 +51,7 @@ const CONFIG_VARS = [
51
51
  ["namespace", "default"],
52
52
  ["processingNamespaces", ["default"]],
53
53
  ["collectEventQueueMetrics", false],
54
+ ["authCacheExpiryReductionMaxPercent", 10],
54
55
  ];
55
56
 
56
57
  /**
@@ -81,6 +82,7 @@ const CONFIG_VARS = [
81
82
  * @param {string} [options.namespace=default] - Default namespace in which events are published
82
83
  * @param {string} [options.processingNamespaces=[default]] - Namespaces which the application processes
83
84
  * @param {boolean} [options.collectEventQueueMetrics=false] - Enable collection of event queue metrics (pending/inProgress counters) stored in Redis and exposed via OpenTelemetry gauges.
85
+ * @param {number} [options.authCacheExpiryReductionMaxPercent=10] - Maximum percentage (allowed range 0-80) by which the XSUAA token TTL is shortened when stored in the authContext cache. The actual reduction is randomized per token fetch in `[0, value]` so multi-tenant deployments do not all expire their cached tokens at the same time and stampede the XSUAA token endpoint. Values outside the allowed range throw on initialization.
84
86
  */
85
87
  const initialize = async (options = {}) => {
86
88
  if (config.initialized) {
@@ -1,20 +1,15 @@
1
1
  "use strict";
2
2
 
3
- const COMPONENT = "eventQueue/SetIntervalDriftSafe";
4
-
5
3
  class SetIntervalDriftSafe {
6
4
  #adjustedInterval;
7
5
  #interval;
8
6
  #expectedCycleTime = 0;
9
- #nextTickScheduledFor;
10
- #logger;
11
7
  #shouldRun = true;
12
8
  #lastTimeoutId;
13
9
 
14
10
  constructor(interval) {
15
11
  this.#interval = interval;
16
12
  this.#adjustedInterval = interval;
17
- this.#logger = cds.log(COMPONENT);
18
13
  }
19
14
 
20
15
  start(fn) {
@@ -34,7 +29,6 @@ class SetIntervalDriftSafe {
34
29
  this.#adjustedInterval = this.#interval - (now - this.#expectedCycleTime);
35
30
  this.#expectedCycleTime += this.#interval;
36
31
  }
37
- this.#nextTickScheduledFor = now + this.#adjustedInterval;
38
32
  const timeoutId = setTimeout(() => {
39
33
  if (!this.#shouldRun) {
40
34
  return;
@@ -99,7 +99,10 @@ const _getNewAuthContext = async (tenantId) => {
99
99
  const token = await authService.fetchClientCredentialsToken({ zid: tenantId });
100
100
  const tokenInfo = new xssec.XsuaaToken(token.access_token);
101
101
  const authInfo = new xssec.XsuaaSecurityContext(authService, tokenInfo);
102
- return [tokenInfo.getExpirationDate().getTime() - Date.now(), [null, authInfo]];
102
+ const ttl = tokenInfo.getExpirationDate().getTime() - Date.now();
103
+ // Randomized to avoid synchronized cache expiry across tenants stampeding XSUAA on token refresh.
104
+ const reductionPercent = Math.random() * config.authCacheExpiryReductionMaxPercent;
105
+ return [ttl * (1 - reductionPercent / 100), [null, authInfo]];
103
106
  } catch (err) {
104
107
  cds.log(COMPONENT_NAME).warn("failed to request authContext", {
105
108
  err: err.message,