@cap-js-community/event-queue 1.11.0-beta.3 → 1.11.0-beta.5

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/db/Lock.cds CHANGED
@@ -1,6 +1,5 @@
1
1
  namespace sap.eventqueue;
2
2
 
3
- using cuid from '@sap/cds/common';
4
3
  using managed from '@sap/cds/common';
5
4
 
6
5
  @assert.unique.semanticKey: [code]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "1.11.0-beta.3",
3
+ "version": "1.11.0-beta.5",
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",
@@ -48,23 +48,23 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@sap/xssec": "^4.6.0",
51
- "cron-parser": "^5.2.0",
51
+ "cron-parser": "^5.3.1",
52
52
  "redis": "^4.7.0",
53
53
  "verror": "^1.10.1",
54
54
  "yaml": "^2.7.1"
55
55
  },
56
56
  "devDependencies": {
57
- "@cap-js/cds-test": "^0.3.0",
58
- "@cap-js/hana": "^1.8.0",
59
- "@cap-js/sqlite": "^1.10.0",
60
- "@sap/cds": "^8.9.0",
61
- "@sap/cds-dk": "^8.8.0",
57
+ "@cap-js/cds-test": "^0.4.0",
58
+ "@cap-js/hana": "^2.2.0",
59
+ "@cap-js/sqlite": "^2.0.1",
60
+ "@sap/cds": "^9.3.1",
61
+ "@sap/cds-dk": "^9.3.1",
62
62
  "eslint": "^8.57.0",
63
63
  "eslint-config-prettier": "^9.1.0",
64
64
  "eslint-plugin-jest": "^28.6.0",
65
65
  "eslint-plugin-node": "^11.1.0",
66
66
  "express": "^4.21.2",
67
- "hdb": "^0.19.10",
67
+ "hdb": "^2.25.1",
68
68
  "jest": "^29.7.0",
69
69
  "prettier": "^2.8.8",
70
70
  "sqlite3": "^5.1.7",
@@ -1016,7 +1016,7 @@ class EventQueueProcessorBase {
1016
1016
 
1017
1017
  // NOTE: do not pass current date as we always want to calc. a future date
1018
1018
  const cronExpression = CronExpressionParser.parse(this.#eventConfig.cron, {
1019
- tz: eventConfig.tz,
1019
+ tz: this.#eventConfig.tz,
1020
1020
  });
1021
1021
  return cronExpression.next();
1022
1022
  }
package/src/config.js CHANGED
@@ -114,11 +114,12 @@ class Config {
114
114
  #redisNamespace;
115
115
  #publishEventBlockList;
116
116
  #crashOnRedisUnavailable;
117
- #tenantIdFilterTokenInfoCb;
117
+ #tenantIdFilterAuthContextCb;
118
118
  #tenantIdFilterEventProcessingCb;
119
119
  #configEvents;
120
120
  #configPeriodicEvents;
121
121
  #enableAdminService;
122
+ #disableProcessingOfSuspendedTenants;
122
123
  static #instance;
123
124
  constructor() {
124
125
  this.#logger = cds.log(COMPONENT_NAME);
@@ -196,9 +197,7 @@ class Config {
196
197
  result = config.value.test(this.#env.applicationName);
197
198
  } else {
198
199
  const shouldBeProcessedBasedOnAppName = appNameConfig[this.#env.applicationName];
199
- if (!shouldBeProcessedBasedOnAppName) {
200
- result = config.value === this.#env.applicationName;
201
- }
200
+ result = !!shouldBeProcessedBasedOnAppName;
202
201
  }
203
202
  if (result) {
204
203
  break;
@@ -484,10 +483,11 @@ class Config {
484
483
  delete base.interval;
485
484
  }
486
485
 
487
- result[fnName] = Object.assign(
486
+ const subType = `${name}.${fnName}`;
487
+ result[subType] = Object.assign(
488
488
  {
489
489
  type: CAP_EVENT_TYPE,
490
- subType: `${name}.${fnName}`,
490
+ subType,
491
491
  impl: "./outbox/EventQueueGenericOutboxHandler",
492
492
  internalEvent: true,
493
493
  },
@@ -771,12 +771,12 @@ class Config {
771
771
  this.#crashOnRedisUnavailable = value;
772
772
  }
773
773
 
774
- get tenantIdFilterTokenInfo() {
775
- return this.#tenantIdFilterTokenInfoCb;
774
+ get tenantIdFilterAuthContext() {
775
+ return this.#tenantIdFilterAuthContextCb;
776
776
  }
777
777
 
778
- set tenantIdFilterTokenInfo(value) {
779
- this.#tenantIdFilterTokenInfoCb = value;
778
+ set tenantIdFilterAuthContext(value) {
779
+ this.#tenantIdFilterAuthContextCb = value;
780
780
  }
781
781
 
782
782
  get tenantIdFilterEventProcessing() {
@@ -985,6 +985,14 @@ class Config {
985
985
  this.#enableAdminService = value;
986
986
  }
987
987
 
988
+ get disableProcessingOfSuspendedTenants() {
989
+ return this.#disableProcessingOfSuspendedTenants;
990
+ }
991
+
992
+ set disableProcessingOfSuspendedTenants(value) {
993
+ this.#disableProcessingOfSuspendedTenants = value;
994
+ }
995
+
988
996
  /**
989
997
  @return { Config }
990
998
  **/
package/src/constants.js CHANGED
@@ -22,6 +22,6 @@ module.exports = {
22
22
  },
23
23
  TenantIdCheckTypes: {
24
24
  eventProcessing: "eventProcessing",
25
- getTokenInfo: "getTokenInfo",
25
+ getAuthContext: "getAuthContext",
26
26
  },
27
27
  };
package/src/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export declare type EventProcessingStatusType = (typeof EventProcessingStatus)[E
14
14
 
15
15
  export declare const TenantIdCheckTypes: {
16
16
  eventProcessing: "eventProcessing";
17
- getTokenInfo: "getTokenInfo";
17
+ getAuthContext: "getAuthContext";
18
18
  };
19
19
 
20
20
  export declare const TransactionMode: {
@@ -215,8 +215,8 @@ declare class Config {
215
215
  get publishEventBlockList(): any;
216
216
  set crashOnRedisUnavailable(value: any);
217
217
  get crashOnRedisUnavailable(): any;
218
- set tenantIdFilterTokenInfo(value: any);
219
- get tenantIdFilterTokenInfo(): any;
218
+ set tenantIdFilterAuthContext(value: any);
219
+ get tenantIdFilterAuthContext(): any;
220
220
  set tenantIdFilterEventProcessing(value: any);
221
221
  get tenantIdFilterEventProcessing(): any;
222
222
  set runInterval(value: any);
package/src/initialize.js CHANGED
@@ -47,6 +47,7 @@ const CONFIG_VARS = [
47
47
  ["publishEventBlockList", true],
48
48
  ["crashOnRedisUnavailable", false],
49
49
  ["enableAdminService", false],
50
+ ["disableProcessingOfSuspendedTenants", true],
50
51
  ];
51
52
 
52
53
  /**
@@ -327,9 +327,11 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
327
327
  }
328
328
 
329
329
  async #setContextUser(context, userId, reg) {
330
+ const authInfo = await common.getAuthContext(context.tenant);
330
331
  context.user = new cds.User.Privileged({
331
332
  id: userId,
332
- tokenInfo: await common.getTokenInfo(this.baseContext.tenant),
333
+ authInfo,
334
+ tokenInfo: authInfo?.token,
333
335
  });
334
336
  if (reg) {
335
337
  reg.user = context.user;
@@ -52,7 +52,7 @@ function outboxed(srv, customOpts) {
52
52
  outboxOpts = config.addCAPOutboxEventSpecificAction(srv.name, req.event);
53
53
  }
54
54
 
55
- if (outboxOpts.kind === "persistent-outbox") {
55
+ if (["persistent-outbox", "persistent-queue"].includes(outboxOpts.kind)) {
56
56
  await _mapToEventAndPublish(context, srv.name, req, !!specificSettings);
57
57
  return;
58
58
  }
@@ -7,11 +7,17 @@ const { EventProcessingStatus } = require("./constants");
7
7
  const { processChunkedSync } = require("./shared/common");
8
8
  const eventConfig = require("./config");
9
9
 
10
- const COMPONENT_NAME = "/eventQueue/periodicEvents";
11
10
  const CHUNK_SIZE_INSERT_PERIODIC_EVENTS = 4;
12
11
 
12
+ const ALLOWED_PERIODIC_SEC_DIFF = 30;
13
+
14
+ const COMPONENT_NAME = "/eventQueue/periodicEvents";
15
+
13
16
  const checkAndInsertPeriodicEvents = async (context) => {
14
17
  const now = new Date();
18
+ cds.log(COMPONENT_NAME).info("updating periodic events", {
19
+ tenant: context.tenant,
20
+ });
15
21
  const tx = cds.tx(context);
16
22
  const baseCqn = SELECT.from(eventConfig.tableNameEventQueue)
17
23
  .where([
@@ -117,11 +123,16 @@ const _determineChangedCron = (existingEventsCron) => {
117
123
  const config = eventConfig.getEventConfig(event.type, event.subType);
118
124
  const eventStartAfter = new Date(event.startAfter);
119
125
  const eventCreatedAt = new Date(event.createdAt);
126
+ const randomOffset = config.randomOffset ?? eventConfig.randomOffsetPeriodicEvents ?? 0;
120
127
  const cronExpression = CronExpressionParser.parse(config.cron, {
121
128
  currentDate: eventCreatedAt,
122
129
  tz: config.tz,
123
130
  });
124
- return Math.abs(cronExpression.next().getTime() - eventStartAfter.getTime()) > 30 * 1000; // report as changed if diff created than 30 seconds
131
+ // report as changed if diff created than ALLOWED_PERIODIC_SEC_DIFF + the random event offset seconds
132
+ return (
133
+ Math.abs(cronExpression.next().getTime() - eventStartAfter.getTime()) >
134
+ (ALLOWED_PERIODIC_SEC_DIFF + randomOffset) * 1000
135
+ );
125
136
  });
126
137
  };
127
138
 
@@ -318,8 +318,11 @@ const _checkEventIsBlocked = async (baseInstance) => {
318
318
 
319
319
  const _processEvent = async (eventTypeInstance, processContext, key, queueEntries, payload) => {
320
320
  let traceContext;
321
- if (queueEntries.length === 1 && eventTypeInstance.inheritTraceContext) {
322
- traceContext = queueEntries[0].context?.traceContext;
321
+ if (eventTypeInstance.inheritTraceContext) {
322
+ const uniqueTraceContext = [...new Set(queueEntries.map((entry) => entry.context?.traceContext).filter((a) => a))];
323
+ if (uniqueTraceContext.length === 1) {
324
+ traceContext = uniqueTraceContext[0];
325
+ }
323
326
  }
324
327
 
325
328
  return await trace(
@@ -119,7 +119,8 @@ const _processLocalWithoutRedis = async (tenantId, events) => {
119
119
  let context = {};
120
120
  if (tenantId) {
121
121
  const user = await cds.tx({ tenant: tenantId }, async () => {
122
- return new cds.User.Privileged({ id: config.userId, tokenInfo: await common.getTokenInfo(tenantId) });
122
+ const authInfo = await common.getAuthContext(tenantId);
123
+ return new cds.User.Privileged({ id: config.userId, authInfo, tokenInfo: authInfo?.token });
123
124
  });
124
125
  context = {
125
126
  tenant: tenantId,
@@ -78,7 +78,8 @@ const _messageHandlerProcessEvents = async (messageData) => {
78
78
  }
79
79
 
80
80
  const user = await cds.tx({ tenant: tenantId }, async () => {
81
- return new cds.User.Privileged({ id: config.userId, tokenInfo: await common.getTokenInfo(tenantId) });
81
+ const authInfo = await common.getAuthContext(tenantId);
82
+ return new cds.User.Privileged({ id: config.userId, authInfo, tokenInfo: authInfo?.token });
82
83
  });
83
84
  const tenantContext = {
84
85
  tenant: tenantId,
@@ -38,34 +38,27 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
38
38
  for (const { type, subType } of entries) {
39
39
  if (eventConfig.isCapOutboxEvent(type)) {
40
40
  const [srvName, actionName] = subType.split(".");
41
- cds.connect
42
- .to(srvName)
43
- .then((service) => {
44
- if (!filterAppSpecificEvents) {
45
- return; // will be done in finally
46
- }
47
-
41
+ try {
42
+ const service = await cds.connect.to(srvName);
43
+ if (filterAppSpecificEvents) {
48
44
  if (!service) {
49
- return;
45
+ continue;
50
46
  }
51
47
  cds.outboxed(service);
52
48
  if (actionName) {
53
49
  config.addCAPOutboxEventSpecificAction(srvName, actionName);
54
50
  }
55
- if (filterAppSpecificEvents) {
56
- if (eventConfig.shouldBeProcessedInThisApplication(type, subType)) {
57
- result.push({ type, subType });
58
- }
59
- } else {
60
- result.push({ type, subType });
61
- }
62
- })
63
- .catch(() => {})
64
- .finally(() => {
65
- if (!filterAppSpecificEvents) {
51
+ if (eventConfig.shouldBeProcessedInThisApplication(type, subType)) {
66
52
  result.push({ type, subType });
67
53
  }
68
- });
54
+ }
55
+ } catch {
56
+ /* ignore catch */
57
+ } finally {
58
+ if (!filterAppSpecificEvents) {
59
+ result.push({ type, subType });
60
+ }
61
+ }
69
62
  } else {
70
63
  if (filterAppSpecificEvents) {
71
64
  if (
@@ -138,16 +138,22 @@ const _executeEventsAllTenantsRedis = async (tenantIds) => {
138
138
  if (!couldAcquireLock) {
139
139
  return;
140
140
  }
141
+ } catch (err) {
142
+ logger.error("executing event queue run for multi instance and tenant failed", err);
143
+ }
141
144
 
142
- for (const tenantId of tenantIds) {
145
+ for (const tenantId of tenantIds) {
146
+ try {
143
147
  await cds.tx({ tenant: tenantId }, async (tx) => {
144
148
  await trace(
145
149
  tx.context,
146
150
  "get-openEvents-and-publish",
147
151
  async () => {
152
+ const authInfo = await common.getAuthContext(tenantId);
148
153
  tx.context.user = new cds.User.Privileged({
149
154
  id: config.userId,
150
- tokenInfo: await common.getTokenInfo(tenantId),
155
+ authInfo,
156
+ tokenInfo: authInfo?.token,
151
157
  });
152
158
  const entries = await openEvents.getOpenQueueEntries(tx, false);
153
159
  logger.info("broadcasting events for run", {
@@ -168,9 +174,9 @@ const _executeEventsAllTenantsRedis = async (tenantIds) => {
168
174
  { newRootSpan: true }
169
175
  );
170
176
  });
177
+ } catch (err) {
178
+ logger.error("broadcasting events for tenant failed", { tenantId }, err);
171
179
  }
172
- } catch (err) {
173
- logger.info("executing event queue run for multi instance and tenant failed", err);
174
180
  }
175
181
  };
176
182
 
@@ -179,23 +185,30 @@ const _executeEventsAllTenants = async (tenantIds, runId) => {
179
185
  for (const tenantId of tenantIds) {
180
186
  const id = cds.utils.uuid();
181
187
  let tenantContext;
182
- const events = await trace(
183
- { id, tenant: tenantId },
184
- "fetch-openEvents-and-tokenInfo",
185
- async () => {
186
- const user = await cds.tx({ tenant: tenantId }, async () => {
187
- return new cds.User.Privileged({ id: config.userId, tokenInfo: await common.getTokenInfo(tenantId) });
188
- });
189
- tenantContext = {
190
- tenant: tenantId,
191
- user,
192
- };
193
- return await cds.tx(tenantContext, async (tx) => {
194
- return await openEvents.getOpenQueueEntries(tx);
195
- });
196
- },
197
- { newRootSpan: true }
198
- );
188
+ let events;
189
+ try {
190
+ events = await trace(
191
+ { id, tenant: tenantId },
192
+ "fetch-openEvents-and-authInfo",
193
+ async () => {
194
+ const user = await cds.tx({ tenant: tenantId }, async () => {
195
+ const authInfo = await common.getAuthContext(tenantId);
196
+ return new cds.User.Privileged({ id: config.userId, authInfo, tokenInfo: authInfo?.token });
197
+ });
198
+ tenantContext = {
199
+ tenant: tenantId,
200
+ user,
201
+ };
202
+ return await cds.tx(tenantContext, async (tx) => {
203
+ return await openEvents.getOpenQueueEntries(tx);
204
+ });
205
+ },
206
+ { newRootSpan: true }
207
+ );
208
+ } catch (err) {
209
+ cds.log(COMPONENT_NAME).error("fetching open events for tenant failed", { tenantId }, err);
210
+ continue;
211
+ }
199
212
 
200
213
  if (!events.length) {
201
214
  continue;
@@ -248,7 +261,8 @@ const _executePeriodicEventsAllTenants = async (tenantIds) => {
248
261
  for (const tenantId of tenantIds) {
249
262
  try {
250
263
  const user = await cds.tx({ tenant: tenantId }, async () => {
251
- return new cds.User.Privileged({ id: config.userId, tokenInfo: await common.getTokenInfo(tenantId) });
264
+ const authInfo = await common.getAuthContext(tenantId);
265
+ return new cds.User.Privileged({ id: config.userId, authInfo, tokenInfo: authInfo?.token });
252
266
  });
253
267
  const tenantContext = {
254
268
  tenant: tenantId,
@@ -279,7 +293,7 @@ const _singleTenantDb = async () => {
279
293
  const id = cds.utils.uuid();
280
294
  const events = await trace(
281
295
  { id },
282
- "fetch-openEvents-and-tokenInfo",
296
+ "fetch-openEvents-and-authInfo",
283
297
  async () => {
284
298
  return await cds.tx({}, async (tx) => {
285
299
  return await openEvents.getOpenQueueEntries(tx);
@@ -499,6 +513,7 @@ const _checkPeriodicEventsSingleTenantOneTime = async () => {
499
513
  tenantScoped: false,
500
514
  });
501
515
  if (!couldAcquireLock) {
516
+ logger.info("skipping updating periodic events - lock not acquired");
502
517
  return;
503
518
  }
504
519
  return await cds.tx({}, async (tx) => await periodicEvents.checkAndInsertPeriodicEvents(tx.context));
@@ -519,7 +534,7 @@ const _checkPeriodicEventsSingleTenant = async (context) => {
519
534
  try {
520
535
  logger.info("executing updating periodic events", {
521
536
  tenantId: context.tenant,
522
- subdomain: context.user?.tokenInfo?.extAttributes?.zdn,
537
+ subdomain: context.user?.authInfo?.getSubdomain?.(),
523
538
  });
524
539
  await periodicEvents.checkAndInsertPeriodicEvents(context);
525
540
  } catch (err) {
@@ -6,10 +6,13 @@ const cds = require("@sap/cds");
6
6
  const config = require("../config");
7
7
  const common = require("./common");
8
8
  const { TenantIdCheckTypes } = require("../constants");
9
+ const { limiter } = require("./common");
9
10
 
10
11
  const VERROR_CLUSTER_NAME = "ExecuteInNewTransactionError";
11
12
  const COMPONENT_NAME = "/eventQueue/cdsHelper";
12
13
 
14
+ const CONCURRENCY_AUTH_INFO = 3;
15
+
13
16
  /**
14
17
  * Execute logic in a new managed CDS transaction context, auto-handling commit, rollback and error/exception situations.
15
18
  * Includes logging of start, end and error situation with additional info object and unique transaction id (txId)
@@ -25,7 +28,8 @@ async function executeInNewTransaction(context = {}, transactionTag, fn, args, {
25
28
  const logger = cds.log(COMPONENT_NAME);
26
29
  let transactionRollbackPromise = Promise.resolve(false);
27
30
  try {
28
- const user = new cds.User.Privileged({ id: config.userId, tokenInfo: await common.getTokenInfo(context.tenant) });
31
+ const authInfo = await common.getAuthContext(context.tenant);
32
+ const user = new cds.User.Privileged({ id: config.userId, authInfo, tokenInfo: authInfo?.token });
29
33
  if (cds.db.kind === "hana") {
30
34
  await cds.tx(
31
35
  {
@@ -139,18 +143,31 @@ const getAllTenantIds = async () => {
139
143
  return null;
140
144
  }
141
145
 
142
- return response
143
- .map((tenant) => tenant.subscribedTenantId ?? tenant.tenant)
144
- .reduce(async (result, tenantId) => {
145
- result = await result;
146
- if (await common.isTenantIdValidCb(TenantIdCheckTypes.eventProcessing, tenantId)) {
147
- result.push(tenantId);
146
+ const tenantIds = response.map((tenant) => tenant.subscribedTenantId ?? tenant.tenant);
147
+ const suspendedTenants = {};
148
+ if (config.disableProcessingOfSuspendedTenants) {
149
+ await limiter(CONCURRENCY_AUTH_INFO, tenantIds, async (tenantId) => {
150
+ const result = await common.getAuthContext(tenantId, { returnError: true });
151
+ // NOTE: only 404 errors are propagated all others are ignored
152
+ if (result?.[0]) {
153
+ suspendedTenants[tenantId] = true;
154
+ cds.log(COMPONENT_NAME).info("skip event-queue processing, tenant suspended", { tenantId });
148
155
  }
149
- return result;
150
- }, []);
156
+ });
157
+ }
158
+
159
+ return tenantIds.reduce(async (result, tenantId) => {
160
+ result = await result;
161
+ if (!suspendedTenants[tenantId] && (await common.isTenantIdValidCb(TenantIdCheckTypes.eventProcessing, tenantId))) {
162
+ result.push(tenantId);
163
+ }
164
+ return result;
165
+ }, []);
151
166
  };
152
167
 
153
- const getAllTenantWithSubdomain = async () => {
168
+ const TENANT_COLUMNS = ["subscribedSubdomain", "createdAt", "modifiedAt"];
169
+
170
+ const getAllTenantWithMetadata = async () => {
154
171
  const response = await _getAllTenantBase();
155
172
  if (!response) {
156
173
  return null;
@@ -160,10 +177,19 @@ const getAllTenantWithSubdomain = async () => {
160
177
  const tenantId = row.subscribedTenantId ?? row.tenant;
161
178
  result = await result;
162
179
  if (await common.isTenantIdValidCb(TenantIdCheckTypes.eventProcessing, tenantId)) {
163
- result.push({
164
- ID: tenantId,
165
- subdomain: row.subscribedSubdomain,
166
- });
180
+ const data = Object.entries(row).reduce(
181
+ (result, [key, value]) => {
182
+ if (TENANT_COLUMNS.includes(key)) {
183
+ result[key] = value;
184
+ } else {
185
+ result.metadata[key] = value;
186
+ }
187
+ return result;
188
+ },
189
+ { metadata: {} }
190
+ );
191
+ data.metadata = JSON.stringify(data.metadata);
192
+ result.push(data);
167
193
  }
168
194
  return result;
169
195
  }, []);
@@ -172,5 +198,5 @@ const getAllTenantWithSubdomain = async () => {
172
198
  module.exports = {
173
199
  executeInNewTransaction,
174
200
  getAllTenantIds,
175
- getAllTenantWithSubdomain,
201
+ getAllTenantWithMetadata,
176
202
  };
@@ -7,9 +7,11 @@ const xssec = require("@sap/xssec");
7
7
  const VError = require("verror");
8
8
 
9
9
  const config = require("../config");
10
+ const { ExpiringLazyCache } = require("./lazyCache");
10
11
  const { TenantIdCheckTypes } = require("../constants");
11
12
 
12
- const MARGIN_AUTH_INFO_EXPIRY = 60 * 1000;
13
+ const EXPIRE_TIME_TENANT_404 = 10 * 60 * 1000; // 10 minutes
14
+
13
15
  const COMPONENT_NAME = "/eventQueue/common";
14
16
 
15
17
  const arrayToFlatMap = (array, key = "ID") => {
@@ -87,64 +89,61 @@ const processChunkedSync = (inputs, chunkSize, chunkHandler) => {
87
89
 
88
90
  const hashStringTo32Bit = (value) => crypto.createHash("sha256").update(String(value)).digest("base64").slice(0, 32);
89
91
 
90
- const _getNewTokenInfo = async (tenantId) => {
91
- const tokenInfoCache = getTokenInfo._tokenInfoCache;
92
- tokenInfoCache[tenantId] = tokenInfoCache[tenantId] ?? {};
92
+ const _getNewAuthContext = async (tenantId) => {
93
93
  try {
94
- if (!_getNewTokenInfo._xsuaaService) {
95
- _getNewTokenInfo._xsuaaService = new xssec.XsuaaService(cds.requires.auth.credentials);
94
+ if (!_getNewAuthContext._xsuaaService) {
95
+ _getNewAuthContext._xsuaaService = new xssec.XsuaaService(cds.requires.auth.credentials);
96
96
  }
97
- const authService = _getNewTokenInfo._xsuaaService;
97
+ const authService = _getNewAuthContext._xsuaaService;
98
98
  const token = await authService.fetchClientCredentialsToken({ zid: tenantId });
99
99
  const tokenInfo = new xssec.XsuaaToken(token.access_token);
100
- tokenInfoCache[tenantId].expireTs = tokenInfo.getExpirationDate().getTime() - MARGIN_AUTH_INFO_EXPIRY;
101
- return tokenInfo;
100
+ const authInfo = new xssec.XsuaaSecurityContext(authService, tokenInfo);
101
+ return [tokenInfo.getExpirationDate().getTime() - Date.now(), [null, authInfo]];
102
102
  } catch (err) {
103
- tokenInfoCache[tenantId] = null;
104
- cds.log(COMPONENT_NAME).warn("failed to request tokenInfo", {
103
+ cds.log(COMPONENT_NAME).warn("failed to request authContext", {
105
104
  err: err.message,
106
105
  responseCode: err.responseCode,
107
106
  responseText: err.responseText,
108
107
  });
108
+
109
+ if (err.responseCode === 404) {
110
+ return [EXPIRE_TIME_TENANT_404, [err, null]];
111
+ }
112
+ return [0, null];
109
113
  }
110
114
  };
111
115
 
112
- const getTokenInfo = async (tenantId) => {
113
- if (!(await isTenantIdValidCb(TenantIdCheckTypes.getTokenInfo, tenantId))) {
116
+ const getAuthContext = async (tenantId, { returnError = false } = {}) => {
117
+ if (!(await isTenantIdValidCb(TenantIdCheckTypes.getAuthContext, tenantId))) {
114
118
  return null;
115
119
  }
116
120
 
117
121
  if (!cds.requires?.auth?.credentials) {
118
- return null; // no credentials not tokenInfo
122
+ return null; // no credentials not authContext
119
123
  }
120
124
 
121
125
  if (!config.isMultiTenancy) {
122
126
  return null; // does only make sense for multi tenancy
123
127
  }
124
128
 
125
- if (!cds.requires?.auth.kind.match(/jwt|xsuaa/i)) {
129
+ if (!cds.requires?.auth.kind.match(/jwt|xsuaa/i) && !cds.requires?.xsuaa) {
126
130
  return null;
127
131
  }
128
132
 
129
- getTokenInfo._tokenInfoCache = getTokenInfo._tokenInfoCache ?? {};
130
- const tokenInfoCache = getTokenInfo._tokenInfoCache;
131
- // not existing or existing but expired
132
- if (
133
- !tokenInfoCache[tenantId] ||
134
- (tokenInfoCache[tenantId] && tokenInfoCache[tenantId].expireTs && Date.now() > tokenInfoCache[tenantId].expireTs)
135
- ) {
136
- tokenInfoCache[tenantId] ??= {};
137
- tokenInfoCache[tenantId].value = _getNewTokenInfo(tenantId);
138
- tokenInfoCache[tenantId].expireTs = null;
133
+ getAuthContext._cache = getAuthContext._cache ?? new ExpiringLazyCache();
134
+ const result = await getAuthContext._cache.getSetCb(tenantId, async () => _getNewAuthContext(tenantId));
135
+ if (returnError) {
136
+ return result;
137
+ } else {
138
+ return result?.[1];
139
139
  }
140
- return await tokenInfoCache[tenantId].value;
141
140
  };
142
141
 
143
142
  const isTenantIdValidCb = async (checkType, tenantId) => {
144
143
  let cb;
145
144
  switch (checkType) {
146
- case TenantIdCheckTypes.getTokenInfo:
147
- cb = config.tenantIdFilterTokenInfo;
145
+ case TenantIdCheckTypes.getAuthContext:
146
+ cb = config.tenantIdFilterAuthContext;
148
147
  break;
149
148
  case TenantIdCheckTypes.eventProcessing:
150
149
  cb = config.tenantIdFilterEventProcessing;
@@ -167,10 +166,10 @@ module.exports = {
167
166
  isValidDate,
168
167
  processChunkedSync,
169
168
  hashStringTo32Bit,
170
- getTokenInfo,
169
+ getAuthContext,
171
170
  isTenantIdValidCb,
172
171
  promiseAllDone,
173
172
  __: {
174
- clearTokenInfoCache: () => (getTokenInfo._tokenInfoCache = {}),
173
+ clearAuthContextCache: () => getAuthContext._cache?.clear(),
175
174
  },
176
175
  };
@@ -121,8 +121,9 @@ const _checkLockExistsDb = async (context, fullKey) => {
121
121
 
122
122
  const _releaseLockRedis = async (context, fullKey) => {
123
123
  const client = await redis.createMainClientAndConnect(config.redisOptions);
124
- await client.del(fullKey);
124
+ const result = await client.del(fullKey);
125
125
  delete existingLocks[fullKey];
126
+ return result === 1;
126
127
  };
127
128
 
128
129
  const _releaseLockDb = async (context, fullKey) => {
@@ -130,6 +131,7 @@ const _releaseLockDb = async (context, fullKey) => {
130
131
  await tx.run(DELETE.from(config.tableNameEventLock).where("code =", fullKey));
131
132
  });
132
133
  delete existingLocks[fullKey];
134
+ return true;
133
135
  };
134
136
 
135
137
  const _acquireLockDB = async (
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+
3
+ const DEFAULT_SEPARATOR = "##";
4
+ const DEFAULT_EXPIRATION_GAP = 60 * 1000; // 60 seconds
5
+
6
+ class LazyCache {
7
+ constructor({ separator = DEFAULT_SEPARATOR } = {}) {
8
+ this.__data = Object.create(null);
9
+ this.__separator = separator;
10
+ }
11
+ _separator() {
12
+ return this.__separator;
13
+ }
14
+ _data() {
15
+ return this.__data;
16
+ }
17
+ async _dataSettled() {
18
+ return await Object.entries(this.__data).reduce(async (result, [key, value]) => {
19
+ (await result)[key] = await value;
20
+ return result;
21
+ }, Promise.resolve({}));
22
+ }
23
+ _key(keyOrKeys) {
24
+ return Array.isArray(keyOrKeys) ? keyOrKeys.join(this.__separator) : keyOrKeys;
25
+ }
26
+ has(keyOrKeys) {
27
+ return Object.prototype.hasOwnProperty.call(this.__data, this._key(keyOrKeys));
28
+ }
29
+ get(keyOrKeys) {
30
+ return this.__data[this._key(keyOrKeys)];
31
+ }
32
+ set(keyOrKeys, value) {
33
+ this.__data[this._key(keyOrKeys)] = value;
34
+ return this;
35
+ }
36
+ setCb(keyOrKeys, callback) {
37
+ const resultOrPromise = callback();
38
+ return this.set(
39
+ keyOrKeys,
40
+ resultOrPromise instanceof Promise
41
+ ? resultOrPromise.catch((err) => {
42
+ this.delete(keyOrKeys);
43
+ return Promise.reject(err);
44
+ })
45
+ : resultOrPromise
46
+ );
47
+ }
48
+ getSetCb(keyOrKeys, callback) {
49
+ const key = this._key(keyOrKeys);
50
+ if (!this.has(key)) {
51
+ this.setCb(key, callback);
52
+ }
53
+ return this.get(key);
54
+ }
55
+ count() {
56
+ return Object.keys(this.__data).length;
57
+ }
58
+ delete(keyOrKeys) {
59
+ Reflect.deleteProperty(this.__data, this._key(keyOrKeys));
60
+ return this;
61
+ }
62
+ clear() {
63
+ this.__data = Object.create(null);
64
+ }
65
+ }
66
+
67
+ class ExpiringLazyCache extends LazyCache {
68
+ constructor({ separator = DEFAULT_SEPARATOR, expirationGap = DEFAULT_EXPIRATION_GAP } = {}) {
69
+ super({ separator });
70
+ this.__expirationGap = expirationGap;
71
+ }
72
+ _expiringGap() {
73
+ return this.__expirationGap;
74
+ }
75
+ _isValid(expirationTime, currentTime = Date.now()) {
76
+ return expirationTime && currentTime <= expirationTime;
77
+ }
78
+ has(keyOrKeys, currentTime = Date.now()) {
79
+ if (!super.has(keyOrKeys)) {
80
+ return false;
81
+ }
82
+ const [expirationTime] = super.get(keyOrKeys) ?? [];
83
+ return this._isValid(expirationTime, currentTime);
84
+ }
85
+ get(keyOrKeys, currentTime = Date.now()) {
86
+ const [expirationTime, value] = super.get(keyOrKeys) ?? [];
87
+ return this._isValid(expirationTime, currentTime) ? value : undefined;
88
+ }
89
+ // NOTE the expiration gap is substracted here, because we want to expire a
90
+ // little earlier than necessary.
91
+ // NOTE if the expiration is _less_ than the gap, the value is never valid,
92
+ // we still need to call set, because we want getSetCb to always return
93
+ // when the callback is used.
94
+ set(keyOrKeys, expiration, value, currentTime = Date.now()) {
95
+ return super.set(keyOrKeys, [currentTime + expiration - this.__expirationGap, value]);
96
+ }
97
+
98
+ static _extract(result, extractor) {
99
+ if (!extractor) {
100
+ return result;
101
+ }
102
+ const { expiration, expiry, value, result: extractedResult } = extractor(result);
103
+ return [expiration ?? expiry, value ?? extractedResult];
104
+ }
105
+
106
+ // NOTE callback can either return a pair [expiration, value] or use an extractor to extract the right values from
107
+ // the callback's result
108
+ setCb(keyOrKeys, callback, { currentTime = Date.now(), extractor } = {}) {
109
+ const resultOrPromise = callback();
110
+ if (!(resultOrPromise instanceof Promise)) {
111
+ const [expiration, value] = ExpiringLazyCache._extract(resultOrPromise, extractor);
112
+ return this.set(keyOrKeys, expiration, value, currentTime);
113
+ }
114
+ return this.set(
115
+ keyOrKeys,
116
+ Infinity,
117
+ resultOrPromise
118
+ .catch((err) => {
119
+ this.delete(keyOrKeys);
120
+ return Promise.reject(err);
121
+ })
122
+ .then((result) => {
123
+ const [expiration, value] = ExpiringLazyCache._extract(result, extractor);
124
+ this.set(keyOrKeys, expiration, value, currentTime);
125
+ return value;
126
+ })
127
+ );
128
+ }
129
+
130
+ // NOTE callback can either return a pair [expiration, value] or use an extractor to extract the right values from
131
+ // the callback's result
132
+ getSetCb(keyOrKeys, callback, { currentTime = Date.now(), extractor } = {}) {
133
+ const key = this._key(keyOrKeys);
134
+ if (!this.has(key, currentTime) || !super.has(key)) {
135
+ this.setCb(key, callback, { currentTime, extractor });
136
+ const [, value] = super.get(key);
137
+ return value;
138
+ }
139
+ return this.get(key, currentTime);
140
+ }
141
+ }
142
+
143
+ module.exports = {
144
+ DEFAULT_EXPIRATION_GAP,
145
+ DEFAULT_SEPARATOR,
146
+ LazyCache,
147
+ ExpiringLazyCache,
148
+ };
@@ -13,14 +13,14 @@ service EventQueueAdminService {
13
13
  null as space: String,
14
14
  *
15
15
  } actions {
16
- action setStatusAndAttempts(
17
- // TODO: remove tenant as soon as CAP issue is fixed https://github.tools.sap/cap/issues/issues/18445
18
- @mandatory
19
- tenant: String,
20
- status: db.Status,
21
- @assert.range: [0,100]
22
- attempts: Integer) returns Event;
23
- }
16
+ action setStatusAndAttempts(
17
+ // TODO: remove tenant as soon as CAP issue is fixed https://github.tools.sap/cap/issues/issues/18445
18
+ @mandatory
19
+ tenant: String,
20
+ status: db.Status,
21
+ @assert.range: [0,100]
22
+ attempts: Integer) returns Event;
23
+ }
24
24
 
25
25
  @cds.persistence.skip
26
26
  @readonly
@@ -32,12 +32,22 @@ service EventQueueAdminService {
32
32
  space: String;
33
33
  ttl: Integer;
34
34
  createdAt: Integer;
35
- }
35
+ } actions {
36
+ action releaseLock(
37
+ // TODO: remove tenant as soon as CAP issue is fixed https://github.tools.sap/cap/issues/issues/18445
38
+ @mandatory
39
+ tenant: String,
40
+ @mandatory
41
+ type: String,
42
+ @mandatory
43
+ subType: String) returns Boolean;
44
+ }
36
45
 
37
46
  @readonly
38
47
  @cds.persistence.skip
39
48
  entity Tenant {
40
49
  Key ID: String;
41
50
  subdomain: String;
51
+ metadata: String;
42
52
  }
43
53
  }
@@ -5,6 +5,7 @@ const cdsHelper = require("../../src/shared/cdsHelper");
5
5
  const { EventProcessingStatus } = require("../../src");
6
6
  const config = require("../../src/config");
7
7
  const distributedLock = require("../../src/shared/distributedLock");
8
+ const redisPub = require("../../src/redis/redisPub");
8
9
 
9
10
  module.exports = class AdminService extends cds.ApplicationService {
10
11
  async init() {
@@ -61,7 +62,7 @@ module.exports = class AdminService extends cds.ApplicationService {
61
62
  });
62
63
 
63
64
  this.on("READ", Tenant, async () => {
64
- const tenants = await cdsHelper.getAllTenantWithSubdomain();
65
+ const tenants = await cdsHelper.getAllTenantWithMetadata();
65
66
  return tenants ?? [];
66
67
  });
67
68
 
@@ -82,14 +83,27 @@ module.exports = class AdminService extends cds.ApplicationService {
82
83
  return req.reject(400, "No status or attempts provided");
83
84
  }
84
85
 
85
- await cds.tx({ tenant, headers: { "z-id": tenant } }, async () => {
86
+ const event = await cds.tx({ tenant, headers: { "z-id": tenant } }, async () => {
87
+ const event = await SELECT.one.from(EventDb).where({ ID: req.params[0].ID ?? req.params[0] });
86
88
  await UPDATE.entity(EventDb)
87
89
  .set(updateData)
88
90
  .where({ ID: req.params[0].ID ?? req.params[0] });
91
+ return event;
92
+ });
93
+ redisPub.broadcastEvent(tenant, event).catch(() => {
94
+ /* ignore errors */
89
95
  });
90
96
  return await this.send(new cds.Request({ query: req.query, headers: req.headers }));
91
97
  });
92
98
 
99
+ this.on("releaseLock", async (req) => {
100
+ cds.log("eventQueue").info("Releasing event-queue lock", req.data);
101
+ const { tenant, type, subType } = req.data;
102
+ return await cds.tx({ tenant }, async (tx) => {
103
+ return await distributedLock.releaseLock(tx.context, [type, subType].join("##"));
104
+ });
105
+ });
106
+
93
107
  await super.init();
94
108
  }
95
109