@cap-js-community/event-queue 2.0.0-beta.7 → 2.0.0

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.0.0-beta.7",
3
+ "version": "2.0.0",
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",
@@ -296,7 +296,7 @@ class EventQueueProcessorBase {
296
296
  const statusMap = this.commitOnEventLevel || returnMap ? {} : this.__statusMap;
297
297
  const errorHandler = (error) => {
298
298
  queueEntries.forEach((queueEntry) =>
299
- this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error, statusMap)
299
+ this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error, { statusMap })
300
300
  );
301
301
  this.logger.error(
302
302
  "The supplied status tuple doesn't have the required structure. Setting all entries to error.",
@@ -317,7 +317,7 @@ class EventQueueProcessorBase {
317
317
 
318
318
  try {
319
319
  queueEntryProcessingStatusTuple.forEach(([id, processingStatus]) =>
320
- this.#determineAndAddEventStatusToMap(id, processingStatus, statusMap)
320
+ this.#determineAndAddEventStatusToMap(id, processingStatus, { statusMap })
321
321
  );
322
322
  } catch (error) {
323
323
  errorHandler(error);
@@ -344,11 +344,15 @@ class EventQueueProcessorBase {
344
344
  }
345
345
  }
346
346
 
347
- #determineAndAddEventStatusToMap(id, statusOrUpdateData, statusMap = this.__statusMap) {
347
+ #determineAndAddEventStatusToMap(id, statusOrUpdateData, { statusMap = this.__statusMap, error } = {}) {
348
348
  if (typeof statusOrUpdateData === "number") {
349
349
  statusOrUpdateData = { status: statusOrUpdateData };
350
350
  }
351
351
 
352
+ if (error) {
353
+ statusOrUpdateData.error = error;
354
+ }
355
+
352
356
  if (!statusMap[id]) {
353
357
  statusMap[id] = statusOrUpdateData;
354
358
  return;
@@ -376,9 +380,17 @@ class EventQueueProcessorBase {
376
380
  }
377
381
  );
378
382
  queueEntries.forEach((queueEntry) =>
379
- this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error)
383
+ this.#determineAndAddEventStatusToMap(queueEntry.ID, EventProcessingStatus.Error, { error })
384
+ );
385
+ return Object.fromEntries(
386
+ queueEntries.map((queueEntry) => [
387
+ queueEntry.ID,
388
+ {
389
+ status: EventProcessingStatus.Error,
390
+ error,
391
+ },
392
+ ])
380
393
  );
381
- return Object.fromEntries(queueEntries.map((queueEntry) => [queueEntry.ID, EventProcessingStatus.Error]));
382
394
  }
383
395
 
384
396
  handleErrorDuringPeriodicEventProcessing(error, queueEntry) {
@@ -389,11 +401,12 @@ class EventQueueProcessorBase {
389
401
  });
390
402
  }
391
403
 
392
- async setPeriodicEventStatus(queueEntryIds, status) {
404
+ async setPeriodicEventStatus(queueEntryIds, status, error) {
393
405
  await this.tx.run(
394
406
  UPDATE.entity(this.#config.tableNameEventQueue)
395
407
  .set({
396
408
  status: status,
409
+ ...(error && { error: this.#error2String(error) }),
397
410
  })
398
411
  .where({
399
412
  ID: queueEntryIds,
@@ -409,7 +422,7 @@ class EventQueueProcessorBase {
409
422
  if (typeof entry === "number") {
410
423
  result.status = entry;
411
424
  } else if (typeof entry === "object") {
412
- for (const fieldName of ALLOWED_FIELDS_FOR_UPDATE) {
425
+ for (const fieldName of this.allowedFieldsEventHandler) {
413
426
  if (fieldName in entry) {
414
427
  result[fieldName] = entry[fieldName];
415
428
  }
@@ -444,7 +457,8 @@ class EventQueueProcessorBase {
444
457
  });
445
458
  const ts = new Date().toISOString();
446
459
  const updateData = Object.entries(statusMap).reduce((result, [id, data]) => {
447
- const key = ALLOWED_FIELDS_FOR_UPDATE.map((name) => [name, data[name]])
460
+ const key = this.allowedFieldsEventHandler
461
+ .map((name) => [name, data[name]])
448
462
  .flat()
449
463
  .join("##");
450
464
 
@@ -1360,6 +1374,10 @@ class EventQueueProcessorBase {
1360
1374
  get namespace() {
1361
1375
  return this.#namespace;
1362
1376
  }
1377
+
1378
+ get allowedFieldsEventHandler() {
1379
+ return ALLOWED_FIELDS_FOR_UPDATE;
1380
+ }
1363
1381
  }
1364
1382
 
1365
1383
  module.exports = EventQueueProcessorBase;
package/src/config.js CHANGED
@@ -143,7 +143,7 @@ class Config {
143
143
  }
144
144
 
145
145
  isCapOutboxEvent(type) {
146
- return type === CAP_EVENT_TYPE;
146
+ return [CAP_EVENT_TYPE, [CAP_EVENT_TYPE, SUFFIX_PERIODIC].join("")].includes(type);
147
147
  }
148
148
 
149
149
  hasEventAfterCommitFlag(type, subType, namespace = this.namespace) {
@@ -168,15 +168,27 @@ class Config {
168
168
  return { type: "string", value: str };
169
169
  }
170
170
 
171
- #normalizeSubType(rawSubType) {
172
- const [serviceName, actionName] = rawSubType.split(".");
173
- const actionSpecificCall = this.getCdsOutboxEventSpecificConfig(serviceName, actionName);
174
- return actionSpecificCall ? rawSubType : serviceName;
171
+ normalizeSubType(type, rawSubType) {
172
+ if (![CAP_EVENT_TYPE, [CAP_EVENT_TYPE, SUFFIX_PERIODIC].join("")].includes(type)) {
173
+ return { subType: rawSubType };
174
+ }
175
+
176
+ const serviceParts = rawSubType.split(".");
177
+ let srvName = serviceParts.shift();
178
+ while (!cds.env.requires[srvName] && serviceParts.length) {
179
+ srvName = [srvName, serviceParts.shift()].join(".");
180
+ }
181
+ const actionName = serviceParts.shift();
182
+ const actionSpecificCall = this.getCdsOutboxEventSpecificConfig(srvName, actionName);
183
+ return {
184
+ subType: actionSpecificCall ? rawSubType : srvName,
185
+ actionName,
186
+ srvName,
187
+ };
175
188
  }
176
189
 
177
190
  shouldBeProcessedInThisApplication(type, rawSubType, namespace = this.namespace) {
178
- const subType = this.#normalizeSubType(rawSubType);
179
-
191
+ const { subType } = this.normalizeSubType(type, rawSubType);
180
192
  const config = this.#eventMap[this.generateKey(namespace, type, subType)];
181
193
  const appNameConfig = config._appNameMap;
182
194
  const appInstanceConfig = config._appInstancesMap;
@@ -22,8 +22,8 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
22
22
  }
23
23
 
24
24
  async getQueueEntriesAndSetToInProgress() {
25
- const [serviceName] = this.eventSubType.split(".");
26
- this.__srv = await cds.connect.to(serviceName);
25
+ const { srvName } = config.normalizeSubType(this.eventType, this.eventSubType);
26
+ this.__srv = await cds.connect.to(srvName);
27
27
  this.__srvUnboxed = cds.unboxed(this.__srv);
28
28
  const { handlers, clusterRelevant, specificClusterRelevant } = this.__srvUnboxed.handlers.on.reduce(
29
29
  (result, handler) => {
@@ -309,8 +309,8 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
309
309
  }
310
310
 
311
311
  async processPeriodicEvent(processContext, key, queueEntry) {
312
- const [, action] = this.eventSubType.split(".");
313
- const reg = new cds.Event({ event: action, eventQueue: { processor: this, key, queueEntries: [queueEntry] } });
312
+ const { actionName } = config.normalizeSubType(this.eventType, this.eventSubType);
313
+ const reg = new cds.Event({ event: actionName, eventQueue: { processor: this, key, queueEntries: [queueEntry] } });
314
314
  await this.#setContextUser(processContext, config.userId, reg);
315
315
  await this.__srvUnboxed.tx(processContext).emit(reg);
316
316
  }
@@ -366,7 +366,11 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
366
366
  }
367
367
 
368
368
  if (result instanceof Object && !Array.isArray(result)) {
369
- return queueEntries.map((queueEntry) => [queueEntry.ID, result]);
369
+ const allAllowed = !Object.keys(result).some((name) => !this.allowedFieldsEventHandler.includes(name));
370
+ return queueEntries.map((queueEntry) => [
371
+ queueEntry.ID,
372
+ allAllowed ? result : { status: EventProcessingStatus.Done },
373
+ ]);
370
374
  }
371
375
 
372
376
  if (!Array.isArray(result)) {
@@ -377,7 +381,11 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
377
381
  if (Array.isArray(firstEntry)) {
378
382
  const [, innerResult] = firstEntry;
379
383
  if (innerResult instanceof Object) {
380
- return result;
384
+ const allAllowed = !Object.keys(innerResult).some((name) => !this.allowedFieldsEventHandler.includes(name));
385
+ if (allAllowed) {
386
+ return result;
387
+ }
388
+ return queueEntries.map((queueEntry) => [queueEntry.ID, { status: EventProcessingStatus.Done }]);
381
389
  } else {
382
390
  return result.map(([id, status]) => {
383
391
  return [id, { status }];
@@ -398,6 +406,12 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
398
406
  }
399
407
 
400
408
  delete entry.ID;
409
+ const allAllowed = !Object.keys(entry).some((name) => !this.allowedFieldsEventHandler.includes(name));
410
+
411
+ if (!allAllowed) {
412
+ result.push([ID, { status: EventProcessingStatus.Done }]);
413
+ }
414
+
401
415
  if (!("status" in entry)) {
402
416
  entry.status = EventProcessingStatus.Done;
403
417
  }
@@ -154,6 +154,7 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
154
154
  }
155
155
 
156
156
  let status = EventProcessingStatus.Done;
157
+ let error;
157
158
  await executeInNewTransaction(
158
159
  eventTypeInstance.context,
159
160
  `eventQueue-periodic-process-${eventTypeInstance.eventType}##${eventTypeInstance.eventSubType}`,
@@ -166,6 +167,7 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
166
167
  eventTypeInstance.startPerformanceTracerPeriodicEvents();
167
168
  await eventTypeInstance.processPeriodicEvent(tx.context, queueEntry.ID, queueEntry);
168
169
  } catch (err) {
170
+ error = err;
169
171
  status = EventProcessingStatus.Error;
170
172
  eventTypeInstance.handleErrorDuringPeriodicEventProcessing(err, queueEntry);
171
173
  await tx.rollback();
@@ -190,7 +192,7 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
190
192
  async (tx) => {
191
193
  await trace(eventTypeInstance.context, "periodic-event-set-status", async () => {
192
194
  eventTypeInstance.processEventContext = tx.context;
193
- await eventTypeInstance.setPeriodicEventStatus(queueEntry.ID, status);
195
+ await eventTypeInstance.setPeriodicEventStatus(queueEntry.ID, status, error);
194
196
  });
195
197
  }
196
198
  );
@@ -43,16 +43,16 @@ const _messageHandlerProcessEvents = async (messageData) => {
43
43
  return;
44
44
  }
45
45
 
46
- const [serviceNameOrSubType, actionName] = subType.split(".");
46
+ const { srvName, actionName } = config.normalizeSubType(type, subType);
47
47
  if (!config.getEventConfig(type, subType, namespace)) {
48
48
  if (config.isCapOutboxEvent(type)) {
49
49
  try {
50
- const service = await cds.connect.to(serviceNameOrSubType);
50
+ const service = await cds.connect.to(srvName);
51
51
  cds.outboxed(service);
52
52
  if (actionName) {
53
- const specificSettings = config.getCdsOutboxEventSpecificConfig(serviceNameOrSubType, actionName);
53
+ const specificSettings = config.getCdsOutboxEventSpecificConfig(srvName, actionName);
54
54
  if (specificSettings) {
55
- config.addCAPOutboxEventSpecificAction(serviceNameOrSubType, actionName);
55
+ config.addCAPOutboxEventSpecificAction(srvName, actionName);
56
56
  }
57
57
  }
58
58
  } catch (err) {
@@ -39,7 +39,7 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
39
39
  const result = [];
40
40
  for (const { type, subType, namespace } of entries) {
41
41
  if (eventConfig.isCapOutboxEvent(type)) {
42
- const [srvName, actionName] = subType.split(".");
42
+ const { srvName, actionName } = config.normalizeSubType(type, subType);
43
43
  try {
44
44
  const service = await cds.connect.to(srvName);
45
45
  if (filterAppSpecificEvents) {
@@ -165,6 +165,14 @@ const isTenantIdValidCb = async (checkType, tenantId) => {
165
165
  }
166
166
  };
167
167
 
168
+ const cleanUndefined = (input) =>
169
+ Object.entries(input).reduce((acc, [key, value]) => {
170
+ if (value !== undefined) {
171
+ acc[key] = value;
172
+ }
173
+ return acc;
174
+ }, {});
175
+
168
176
  module.exports = {
169
177
  arrayToFlatMap,
170
178
  limiter,
@@ -174,6 +182,7 @@ module.exports = {
174
182
  getAuthContext,
175
183
  isTenantIdValidCb,
176
184
  promiseAllDone,
185
+ cleanUndefined,
177
186
  __: {
178
187
  clearAuthContextCache: () => getAuthContext._cache?.clear(),
179
188
  },
@@ -42,4 +42,17 @@ service EventQueueAdminService {
42
42
  @mandatory
43
43
  subType: String) returns Boolean;
44
44
  }
45
+
46
+ action publishEvent(
47
+ @mandatory
48
+ tenants: array of String,
49
+ @mandatory
50
+ type: String,
51
+ @mandatory
52
+ subType: String,
53
+ referenceEntity: String,
54
+ referenceEntityKey: String,
55
+ @open
56
+ payload: {},
57
+ startAfter: String);
45
58
  }
@@ -1,26 +1,35 @@
1
1
  "use strict";
2
2
 
3
3
  const cds = require("@sap/cds");
4
- const { EventProcessingStatus } = require("../../src");
5
- const config = require("../../src/config");
4
+
5
+ const eventQueue = require("../../src");
6
6
  const distributedLock = require("../../src/shared/distributedLock");
7
7
  const redisPub = require("../../src/redis/redisPub");
8
+ const publishEventHelper = require("./publishEventHelper");
9
+ const commonHelper = require("../../src/shared/common");
10
+
11
+ const COMPONENT_NAME = "/eventQueue/admin";
8
12
 
9
13
  module.exports = class AdminService extends cds.ApplicationService {
10
14
  async init() {
11
15
  const { Event: EventService, Lock: LockService } = this.entities;
12
16
  const { Event: EventDb } = cds.db.entities("sap.eventqueue");
17
+ const { publishEvent } = this.actions;
13
18
  const { landscape, space } = this.getLandscapeAndSpace();
14
19
 
15
20
  this.before("*", (req) => {
16
- if (!config.enableAdminService) {
21
+ if (!eventQueue.config.enableAdminService) {
17
22
  req.reject(403, "Admin service is disabled by configuration");
18
23
  }
19
24
 
25
+ if (req.event === publishEvent.name.split(".")[1]) {
26
+ return;
27
+ }
28
+
20
29
  const headers = Object.assign({}, req.headers, req.req?.headers);
21
30
  const tenant = headers["z-id"] ?? req.data.tenant;
22
31
 
23
- if (config.isMultiTenancy && tenant == null) {
32
+ if (eventQueue.config.isMultiTenancy && tenant == null) {
24
33
  req.reject(400, "Missing tenant ID in request header (z-id)");
25
34
  }
26
35
  req.headers ??= {};
@@ -46,7 +55,7 @@ module.exports = class AdminService extends cds.ApplicationService {
46
55
  });
47
56
 
48
57
  this.on("READ", LockService, async () => {
49
- if (!config.redisEnabled) {
58
+ if (!eventQueue.config.redisEnabled) {
50
59
  return [];
51
60
  }
52
61
  const locks = await distributedLock.getAllLocksRedis();
@@ -59,14 +68,14 @@ module.exports = class AdminService extends cds.ApplicationService {
59
68
 
60
69
  this.on("setStatusAndAttempts", async (req) => {
61
70
  const tenant = req.headers["z-id"];
62
- cds.log("eventQueue").info("Restarting processing for event queue");
71
+ cds.log(COMPONENT_NAME).info("Restarting processing for event queue");
63
72
  const updateData = {};
64
73
 
65
74
  if (Number.isInteger(req.data.attempts)) {
66
75
  updateData.attempts = req.data.attempts;
67
76
  }
68
77
 
69
- if (Object.values(EventProcessingStatus).includes(req.data.status)) {
78
+ if (Object.values(eventQueue.EventProcessingStatus).includes(req.data.status)) {
70
79
  updateData.status = req.data.status;
71
80
  }
72
81
 
@@ -95,6 +104,32 @@ module.exports = class AdminService extends cds.ApplicationService {
95
104
  });
96
105
  });
97
106
 
107
+ this.on(publishEvent, async (req) => {
108
+ const logger = cds.log(COMPONENT_NAME);
109
+ try {
110
+ const { type, subType, referenceEntity, referenceEntityKey, payload, startAfter } = req.data;
111
+ const tenants = await publishEventHelper.resolveTenantInfos(req);
112
+ const eventOptions = commonHelper.cleanUndefined({
113
+ type,
114
+ subType,
115
+ referenceEntity,
116
+ referenceEntityKey,
117
+ payload,
118
+ startAfter,
119
+ });
120
+ const publishInfo = { count: tenants.length, type, subType, tenants: req.data.tenants };
121
+ logger.info("publishing event for tenant(s)", publishInfo);
122
+ for (const tenant of tenants) {
123
+ await cds.tx({ tenant }, async (tx) => {
124
+ await eventQueue.publishEvent(tx, { ...eventOptions });
125
+ });
126
+ }
127
+ logger.info("finished publishing event for tenant(s)", publishInfo);
128
+ } catch (err) {
129
+ logger.error("error publishing event", err);
130
+ }
131
+ });
132
+
98
133
  await super.init();
99
134
  }
100
135
 
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+
3
+ const cdsHelper = require("../../src/shared/cdsHelper");
4
+
5
+ /**
6
+ * @typedef TenantInfo
7
+ * @type object
8
+ * @property {string} tenantId
9
+ * @property {string} subdomain
10
+ */
11
+ /**
12
+ * For service action interfaces, with a tenants = [...] data field this code
13
+ * resolves the inputs into a tenantInfos array.
14
+ *
15
+ * Inputs:
16
+ * - ["all"] give me all tenants
17
+ * - ["self"] give me just the context tenant
18
+ * - [] give me no tenant
19
+ * - ["<tenantId1>", "<tenantId2>", "<subdomain3>", "<subdomain4>",...]
20
+ * give me those tenants where either tenantId or subdomain corresponds to the given inputs
21
+ *
22
+ * @returns {Promise<Array<TenantInfo>>} returns an array of {@link TenantInfo} objects.
23
+ */
24
+ const resolveTenantInfos = async (context, { sortByTenantId = true } = {}) => {
25
+ let result = await _resolveTenantInfos(context);
26
+ if (sortByTenantId) {
27
+ result = result.sort(_orderByTenantId);
28
+ }
29
+ return result;
30
+ };
31
+
32
+ const _resolveTenantInfos = async (context) => {
33
+ if (!Array.isArray(context.data.tenants)) {
34
+ return [];
35
+ }
36
+ for (const tenant of context.data.tenants) {
37
+ if (tenant === "self") {
38
+ return [context.tenant];
39
+ }
40
+ if (["all", "*"].includes(tenant)) {
41
+ return await cdsHelper.getAllTenantIds();
42
+ }
43
+ }
44
+ const contextTenantsMap = Object.fromEntries(context.data.tenants.map((tenant) => [tenant, true]));
45
+ return (await cdsHelper.getAllTenantIds()).filter((tenantId) => contextTenantsMap[tenantId]);
46
+ };
47
+
48
+ const _orderByTenantId = (a, b) => a.localeCompare(b);
49
+
50
+ module.exports = {
51
+ resolveTenantInfos,
52
+ };