@cap-js-community/event-queue 1.11.0-beta.2 → 1.11.0-beta.4

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.2",
3
+ "version": "1.11.0-beta.4",
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
@@ -28,6 +28,7 @@ const DEFAULT_CHECK_FOR_NEXT_CHUNK = true;
28
28
  const SUFFIX_PERIODIC = "_PERIODIC";
29
29
  const CAP_EVENT_TYPE = "CAP_OUTBOX";
30
30
  const CAP_PARALLEL_DEFAULT = 5;
31
+ const CAP_MAX_ATTEMPTS_DEFAULT = 5;
31
32
  const DELETE_TENANT_BLOCK_AFTER_MS = 5 * 60 * 1000;
32
33
  const PRIORITIES = Object.values(Priorities);
33
34
  const UTC_DEFAULT = false;
@@ -113,11 +114,12 @@ class Config {
113
114
  #redisNamespace;
114
115
  #publishEventBlockList;
115
116
  #crashOnRedisUnavailable;
116
- #tenantIdFilterTokenInfoCb;
117
+ #tenantIdFilterAuthContextCb;
117
118
  #tenantIdFilterEventProcessingCb;
118
119
  #configEvents;
119
120
  #configPeriodicEvents;
120
121
  #enableAdminService;
122
+ #disableProcessingOfSuspendedTenants;
121
123
  static #instance;
122
124
  constructor() {
123
125
  this.#logger = cds.log(COMPONENT_NAME);
@@ -195,9 +197,7 @@ class Config {
195
197
  result = config.value.test(this.#env.applicationName);
196
198
  } else {
197
199
  const shouldBeProcessedBasedOnAppName = appNameConfig[this.#env.applicationName];
198
- if (!shouldBeProcessedBasedOnAppName) {
199
- result = config.value === this.#env.applicationName;
200
- }
200
+ result = !!shouldBeProcessedBasedOnAppName;
201
201
  }
202
202
  if (result) {
203
203
  break;
@@ -387,7 +387,7 @@ class Config {
387
387
  kind: config.kind ?? "persistent-outbox",
388
388
  selectMaxChunkSize: config.selectMaxChunkSize ?? config.chunkSize,
389
389
  parallelEventProcessing: config.parallelEventProcessing ?? (config.parallel && CAP_PARALLEL_DEFAULT),
390
- retryAttempts: config.retryAttempts ?? config.maxAttempts,
390
+ retryAttempts: config.retryAttempts ?? config.maxAttempts ?? CAP_MAX_ATTEMPTS_DEFAULT,
391
391
  ...config,
392
392
  });
393
393
  eventConfig.internalEvent = true;
@@ -483,10 +483,11 @@ class Config {
483
483
  delete base.interval;
484
484
  }
485
485
 
486
- result[fnName] = Object.assign(
486
+ const subType = `${name}.${fnName}`;
487
+ result[subType] = Object.assign(
487
488
  {
488
489
  type: CAP_EVENT_TYPE,
489
- subType: `${name}.${fnName}`,
490
+ subType,
490
491
  impl: "./outbox/EventQueueGenericOutboxHandler",
491
492
  internalEvent: true,
492
493
  },
@@ -770,12 +771,12 @@ class Config {
770
771
  this.#crashOnRedisUnavailable = value;
771
772
  }
772
773
 
773
- get tenantIdFilterTokenInfo() {
774
- return this.#tenantIdFilterTokenInfoCb;
774
+ get tenantIdFilterAuthContext() {
775
+ return this.#tenantIdFilterAuthContextCb;
775
776
  }
776
777
 
777
- set tenantIdFilterTokenInfo(value) {
778
- this.#tenantIdFilterTokenInfoCb = value;
778
+ set tenantIdFilterAuthContext(value) {
779
+ this.#tenantIdFilterAuthContextCb = value;
779
780
  }
780
781
 
781
782
  get tenantIdFilterEventProcessing() {
@@ -984,6 +985,14 @@ class Config {
984
985
  this.#enableAdminService = value;
985
986
  }
986
987
 
988
+ get disableProcessingOfSuspendedTenants() {
989
+ return this.#disableProcessingOfSuspendedTenants;
990
+ }
991
+
992
+ set disableProcessingOfSuspendedTenants(value) {
993
+ this.#disableProcessingOfSuspendedTenants = value;
994
+ }
995
+
987
996
  /**
988
997
  @return { Config }
989
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
  /**
@@ -61,7 +61,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
61
61
  }
62
62
  } else {
63
63
  for (const actionName in genericClusterEvents) {
64
- const msg = new cds.Request({
64
+ const reg = new cds.Request({
65
65
  event: EVENT_QUEUE_ACTIONS.CLUSTER,
66
66
  user: this.context.user,
67
67
  eventQueue: {
@@ -74,14 +74,14 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
74
74
  this.#clusterByDataProperty(actionName, genericClusterEvents[actionName], propertyName, cb),
75
75
  },
76
76
  });
77
- const clusterResult = await this.__srvUnboxed.tx(this.context).send(msg);
77
+ const clusterResult = await this.__srvUnboxed.tx(this.context).send(reg);
78
78
  if (this.#validateCluster(clusterResult)) {
79
79
  Object.assign(clusterMap, clusterResult);
80
80
  } else {
81
81
  this.logger.error(
82
82
  "cluster result of handler is not valid. Check the documentation for the expected structure. Continuing without clustering!",
83
83
  {
84
- handler: msg.event,
84
+ handler: reg.event,
85
85
  clusterResult: JSON.stringify(clusterResult),
86
86
  }
87
87
  );
@@ -92,7 +92,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
92
92
  }
93
93
 
94
94
  for (const actionName in specificClusterEvents) {
95
- const msg = new cds.Request({
95
+ const reg = new cds.Request({
96
96
  event: `${EVENT_QUEUE_ACTIONS.CLUSTER}.${actionName}`,
97
97
  user: this.context.user,
98
98
  eventQueue: {
@@ -105,14 +105,14 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
105
105
  this.#clusterByDataProperty(actionName, specificClusterEvents[actionName], propertyName, cb),
106
106
  },
107
107
  });
108
- const clusterResult = await this.__srvUnboxed.tx(this.context).send(msg);
108
+ const clusterResult = await this.__srvUnboxed.tx(this.context).send(reg);
109
109
  if (this.#validateCluster(clusterResult)) {
110
110
  Object.assign(clusterMap, clusterResult);
111
111
  } else {
112
112
  this.logger.error(
113
113
  "cluster result of handler is not valid. Check the documentation for the expected structure. Continuing without clustering!",
114
114
  {
115
- handler: msg.event,
115
+ handler: reg.event,
116
116
  clusterResult: JSON.stringify(clusterResult),
117
117
  }
118
118
  );
@@ -264,12 +264,12 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
264
264
  return payload;
265
265
  }
266
266
 
267
- const { msg, userId } = this.#buildDispatchData(this.context, payload, {
267
+ const { reg, userId } = this.#buildDispatchData(this.context, payload, {
268
268
  queueEntries: [queueEntry],
269
269
  });
270
- msg.event = handlerName;
271
- await this.#setContextUser(this.context, userId, msg);
272
- const data = await this.__srvUnboxed.tx(this.context).send(msg);
270
+ reg.event = handlerName;
271
+ await this.#setContextUser(this.context, userId, reg);
272
+ const data = await this.__srvUnboxed.tx(this.context).send(reg);
273
273
  if (data) {
274
274
  payload.data = data;
275
275
  return payload;
@@ -285,12 +285,12 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
285
285
  return await super.hookForExceededEvents(exceededEvent);
286
286
  }
287
287
 
288
- const { msg, userId } = this.#buildDispatchData(this.context, exceededEvent.payload, {
288
+ const { reg, userId } = this.#buildDispatchData(this.context, exceededEvent.payload, {
289
289
  queueEntries: [exceededEvent],
290
290
  });
291
- await this.#setContextUser(this.context, userId, msg);
292
- msg.event = handlerName;
293
- await this.__srvUnboxed.tx(this.context).send(msg);
291
+ await this.#setContextUser(this.context, userId, reg);
292
+ reg.event = handlerName;
293
+ await this.__srvUnboxed.tx(this.context).send(reg);
294
294
  }
295
295
 
296
296
  // NOTE: Currently not exposed to CAP service; we wait for a valid use case
@@ -310,37 +310,39 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
310
310
 
311
311
  async processPeriodicEvent(processContext, key, queueEntry) {
312
312
  const [, action] = this.eventSubType.split(".");
313
- const msg = new cds.Event({ event: action, eventQueue: { processor: this, key, queueEntries: [queueEntry] } });
314
- await this.#setContextUser(processContext, config.userId, msg);
315
- await this.__srvUnboxed.tx(processContext).emit(msg);
313
+ const reg = new cds.Event({ event: action, eventQueue: { processor: this, key, queueEntries: [queueEntry] } });
314
+ await this.#setContextUser(processContext, config.userId, reg);
315
+ await this.__srvUnboxed.tx(processContext).emit(reg);
316
316
  }
317
317
 
318
318
  #buildDispatchData(context, payload, { key, queueEntries } = {}) {
319
319
  const { useEventQueueUser } = this.eventConfig;
320
320
  const userId = useEventQueueUser ? config.userId : payload.contextUser;
321
- const msg = payload._fromSend ? new cds.Request(payload) : new cds.Event(payload);
321
+ const reg = payload._fromSend ? new cds.Request(payload) : new cds.Event(payload);
322
322
  const invocationFn = payload._fromSend ? "send" : "emit";
323
- delete msg._fromSend; // TODO: this changes the source object --> check after multiple invocations
324
- delete msg.contextUser;
325
- msg.eventQueue = { processor: this, key, queueEntries, payload };
326
- return { msg, userId, invocationFn };
323
+ delete reg._fromSend;
324
+ delete reg.contextUser;
325
+ reg.eventQueue = { processor: this, key, queueEntries, payload };
326
+ return { reg, userId, invocationFn };
327
327
  }
328
328
 
329
- async #setContextUser(context, userId, data) {
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
- if (data) {
335
- data.user = context.user;
336
+ if (reg) {
337
+ reg.user = context.user;
336
338
  }
337
339
  }
338
340
 
339
341
  async processEvent(processContext, key, queueEntries, payload) {
340
342
  try {
341
- const { userId, invocationFn, msg } = this.#buildDispatchData(processContext, payload, { key, queueEntries });
342
- await this.#setContextUser(processContext, userId, msg);
343
- const result = await this.__srvUnboxed.tx(processContext)[invocationFn](msg);
343
+ const { userId, invocationFn, reg } = this.#buildDispatchData(processContext, payload, { key, queueEntries });
344
+ await this.#setContextUser(processContext, userId, reg);
345
+ const result = await this.__srvUnboxed.tx(processContext)[invocationFn](reg);
344
346
  return this.#determineResultStatus(result, queueEntries);
345
347
  } catch (err) {
346
348
  this.logger.error("error processing outboxed service call", err, {
@@ -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
  };
@@ -108,7 +108,7 @@ const _renewLockRedis = async (context, fullKey, expiryTime, { value = "true" }
108
108
 
109
109
  const _checkLockExistsRedis = async (context, fullKey) => {
110
110
  const client = await redis.createMainClientAndConnect(config.redisOptions);
111
- return await client.get(fullKey);
111
+ return await client.exists(fullKey);
112
112
  };
113
113
 
114
114
  const _checkLockExistsDb = async (context, fullKey) => {
@@ -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 (
@@ -191,39 +193,36 @@ const _generateKey = (context, tenantScoped, key) => {
191
193
  };
192
194
 
193
195
  const getAllLocksRedis = async () => {
194
- const client = await redis.createMainClientAndConnect(config.redisOptions);
195
- const batchSize = 500;
196
- const results = [];
197
- let pipeline = client.multi();
196
+ const clientOrCluster = await redis.createMainClientAndConnect(config.redisOptions);
198
197
  const output = [];
199
- let count = 0;
198
+ const results = [];
200
199
 
201
- // NOTE: use SCAN because KEYS is not supported for cluster clients
202
- for await (const key of client.scanIterator({ MATCH: "EVENT*", COUNT: 1000 })) {
203
- const [, tenant, guidOrType, subType] = key.split("##");
204
- if (!subType) {
205
- continue;
206
- }
200
+ let clients;
201
+ if (redis.isClusterMode()) {
202
+ clients = clientOrCluster.masters.map((master) => master.client);
203
+ } else {
204
+ clients = [clientOrCluster];
205
+ }
207
206
 
208
- output.push({
209
- tenant: tenant,
210
- type: guidOrType,
211
- subType: subType,
212
- });
213
- pipeline.ttl(key).get(key);
214
- count++;
207
+ // NOTE: use SCAN because KEYS is not supported for cluster clients
208
+ for (const client of clients) {
209
+ for await (const key of client.scanIterator({ MATCH: "EVENT*", COUNT: 1000 })) {
210
+ const [, tenant, guidOrType, subType] = key.split("##");
211
+ if (!subType) {
212
+ continue;
213
+ }
215
214
 
216
- if (count >= batchSize) {
215
+ const pipeline = client.multi();
216
+ output.push({
217
+ tenant: tenant,
218
+ type: guidOrType,
219
+ subType: subType,
220
+ });
221
+ pipeline.ttl(key).get(key);
217
222
  const replies = await pipeline.exec();
218
223
  results.push(...replies);
219
- pipeline = client.multi();
220
- count = 0;
221
224
  }
222
225
  }
223
- if (count > 0) {
224
- const replies = await pipeline.exec();
225
- results.push(...replies);
226
- }
227
226
 
228
227
  let counter = 0;
229
228
  for (const row of output) {
@@ -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
+ };
@@ -178,6 +178,15 @@ const connectionCheck = async (options) => {
178
178
  });
179
179
  };
180
180
 
181
+ const isClusterMode = () => {
182
+ if (!("__clusterMode" in isClusterMode)) {
183
+ const env = getEnvInstance();
184
+ const { credentials } = env.redisRequires;
185
+ isClusterMode.__clusterMode = credentials.cluster_mode;
186
+ }
187
+ return isClusterMode.__clusterMode;
188
+ };
189
+
181
190
  module.exports = {
182
191
  createClientAndConnect,
183
192
  createMainClientAndConnect,
@@ -186,4 +195,5 @@ module.exports = {
186
195
  closeMainClient,
187
196
  closeSubscribeClient,
188
197
  connectionCheck,
198
+ isClusterMode,
189
199
  };
@@ -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