@cap-js-community/event-queue 1.10.4 → 1.11.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cds CHANGED
@@ -1 +1,2 @@
1
1
  using from './db';
2
+ using from './srv';
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@cap-js-community/event-queue",
3
- "version": "1.10.4",
3
+ "version": "1.11.0-beta.1",
4
4
  "description": "An event queue that enables secure transactional processing of asynchronous and periodic events, featuring instant event processing with Redis Pub/Sub and load distribution across all application instances.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
7
7
  "files": [
8
8
  "src",
9
+ "srv",
9
10
  "db",
10
11
  "cds-plugin.js",
11
12
  "index.cds"
@@ -21,6 +22,8 @@
21
22
  "multi-tenancy"
22
23
  ],
23
24
  "scripts": {
25
+ "start": "PORT=4005 cds-serve",
26
+ "watch": "PORT=4005 cds watch",
24
27
  "test:unit": "jest --testPathIgnorePatterns=\"/test-integration/\"",
25
28
  "test:integration": "jest --testPathIgnorePatterns=\"/test/\" --runInBand --forceExit",
26
29
  "voter:test:integration": "jest --testPathIgnorePatterns=\"/test/\" --forceExit",
@@ -45,7 +48,7 @@
45
48
  },
46
49
  "dependencies": {
47
50
  "@sap/xssec": "^4.6.0",
48
- "cron-parser": "^5.1.0",
51
+ "cron-parser": "^5.2.0",
49
52
  "redis": "^4.7.0",
50
53
  "verror": "^1.10.1",
51
54
  "yaml": "^2.7.1"
package/src/config.js CHANGED
@@ -117,6 +117,7 @@ class Config {
117
117
  #tenantIdFilterEventProcessingCb;
118
118
  #configEvents;
119
119
  #configPeriodicEvents;
120
+ #enableAdminService;
120
121
  static #instance;
121
122
  constructor() {
122
123
  this.#logger = cds.log(COMPONENT_NAME);
@@ -971,6 +972,18 @@ class Config {
971
972
  return this.#eventMap;
972
973
  }
973
974
 
975
+ get developmentMode() {
976
+ return cds.env.profiles.find((profile) => profile === "development");
977
+ }
978
+
979
+ get enableAdminService() {
980
+ return this.#enableAdminService;
981
+ }
982
+
983
+ set enableAdminService(value) {
984
+ this.#enableAdminService = value;
985
+ }
986
+
974
987
  /**
975
988
  @return { Config }
976
989
  **/
package/src/index.d.ts CHANGED
@@ -257,6 +257,8 @@ declare class Config {
257
257
  get insertEventsBeforeCommit(): any;
258
258
  set enableTelemetry(value: any);
259
259
  get enableTelemetry(): any;
260
+ set enableAdminService(value: any);
261
+ get enableAdminService(): any;
260
262
  get isMultiTenancy(): boolean;
261
263
  }
262
264
 
package/src/initialize.js CHANGED
@@ -46,6 +46,7 @@ const CONFIG_VARS = [
46
46
  ["redisNamespace", null],
47
47
  ["publishEventBlockList", true],
48
48
  ["crashOnRedisUnavailable", false],
49
+ ["enableAdminService", false],
49
50
  ];
50
51
 
51
52
  /**
@@ -87,7 +88,7 @@ const initialize = async (options = {}) => {
87
88
  "Event queue initialization skipped: no configFilePath provided, and event queue is not configured as a CAP outbox."
88
89
  );
89
90
  }
90
-
91
+ _disableAdminService();
91
92
  const redisEnabled = config.checkRedisEnabled();
92
93
  let resolveFn;
93
94
  let initFinished = new Promise((resolve) => (resolveFn = resolve));
@@ -201,34 +202,34 @@ const mixConfigVarsWithEnv = (options) => {
201
202
  };
202
203
 
203
204
  const registerCdsShutdown = () => {
204
- const isTestProfile = cds.env.profiles.find((profile) => profile.includes("test"));
205
- if (isTestProfile || !config.redisEnabled) {
205
+ if (!config.developmentMode) {
206
206
  return;
207
207
  }
208
208
  cds.on("shutdown", async () => {
209
209
  return await new Promise((resolve) => {
210
- const timeoutRef = setTimeout(() => {
210
+ let timeoutRef;
211
+ timeoutRef = setTimeout(() => {
211
212
  clearTimeout(timeoutRef);
212
213
  cds.log(COMPONENT).info("shutdown timeout reached - some locks might not have been released!");
213
214
  resolve();
214
215
  }, TIMEOUT_SHUTDOWN);
215
- distributedLock.shutdownHandler().then(() =>
216
- Promise.allSettled([redis.closeMainClient(), redis.closeSubscribeClient()]).then((result) => {
216
+ distributedLock.shutdownHandler().then(() => {
217
+ Promise.allSettled(
218
+ config.redisEnabled ? [redis.closeMainClient(), redis.closeSubscribeClient()] : [Promise.resolve()]
219
+ ).then((result) => {
217
220
  clearTimeout(timeoutRef);
218
221
  resolve(result);
219
- })
220
- );
222
+ });
223
+ });
221
224
  });
222
225
  });
223
226
  };
224
227
 
225
228
  const registerCleanupForDevDb = async () => {
226
- const profile = cds.env.profiles.find((profile) => profile === "development");
227
- if (!profile || process.env.NODE_ENV === "production") {
229
+ if (!config.developmentMode) {
228
230
  return;
229
231
  }
230
-
231
- const tenantIds = await getAllTenantIds();
232
+ const tenantIds = config.isMultiTenancy ? await getAllTenantIds() : [null];
232
233
  for (const tenantId of tenantIds) {
233
234
  await cds.tx({ tenant: tenantId }, async (tx) => {
234
235
  await tx.run(DELETE.from(config.tableNameEventLock));
@@ -260,6 +261,18 @@ const _registerUnsubscribe = () => {
260
261
  });
261
262
  };
262
263
 
264
+ const _disableAdminService = () => {
265
+ if (config.enableAdminService) {
266
+ return;
267
+ }
268
+ cds.on("loaded", (model) => {
269
+ const srvDefinition = model.definitions["EventQueueAdminService"];
270
+ if (srvDefinition) {
271
+ srvDefinition["@protocol"] = "none";
272
+ }
273
+ });
274
+ };
275
+
263
276
  module.exports = {
264
277
  initialize,
265
278
  };
@@ -339,7 +339,7 @@ const _processEvent = async (eventTypeInstance, processContext, key, queueEntrie
339
339
  return eventTypeInstance.handleErrorDuringProcessing(err, queueEntries);
340
340
  }
341
341
  },
342
- { traceContext }
342
+ { traceContext, attributes: { eventIds: queueEntries.map(({ ID }) => ID) } }
343
343
  );
344
344
  };
345
345
 
@@ -22,7 +22,8 @@ const EVENT_QUEUE_RUN_ID = "RUN_ID";
22
22
  const EVENT_QUEUE_RUN_TS = "RUN_TS";
23
23
  const EVENT_QUEUE_RUN_REDIS_CHECK = "RUN_REDIS_CHECK";
24
24
  const EVENT_QUEUE_UPDATE_PERIODIC_EVENTS = "UPDATE_PERIODIC_EVENTS";
25
- let OFFSET_FIRST_RUN = 10 * 1000;
25
+
26
+ let OFFSET_FIRST_RUN;
26
27
 
27
28
  let tenantIdHash;
28
29
  let singleRunDone;
@@ -399,7 +400,7 @@ const _acquireRunId = async (context) => {
399
400
  };
400
401
 
401
402
  const _calculateOffsetForFirstRun = async () => {
402
- let offsetDependingOnLastRun = OFFSET_FIRST_RUN;
403
+ let offsetDependingOnLastRun = OFFSET_FIRST_RUN ?? (config.developmentMode ? 500 : 10 * 1000);
403
404
  const now = Date.now();
404
405
  // NOTE: this is only supported with Redis, because this is a tenant agnostic information
405
406
  // currently there is no proper place to store this information beside t0 schema
@@ -116,21 +116,29 @@ async function executeInNewTransaction(context = {}, transactionTag, fn, args, {
116
116
  }
117
117
  }
118
118
 
119
- const getAllTenantIds = async () => {
119
+ const _getAllTenantBase = async () => {
120
120
  if (!config.isMultiTenancy) {
121
121
  return null;
122
122
  }
123
123
 
124
124
  // NOTE: tmp workaround until cds-mtxs fixes the connect.to service
125
125
  for (let i = 0; i < 10; i++) {
126
- if (cds.services["saas-registry"]) {
126
+ if (cds.services["cds.xt.SaasProvisioningService"] || cds.services["saas-registry"]) {
127
127
  break;
128
128
  }
129
129
  await new Promise((resolve) => setTimeout(resolve, 1000));
130
130
  }
131
131
 
132
132
  const ssp = await cds.connect.to("cds.xt.SaasProvisioningService");
133
- const response = await ssp.get("/tenant");
133
+ return await ssp.get("/tenant");
134
+ };
135
+
136
+ const getAllTenantIds = async () => {
137
+ const response = await _getAllTenantBase();
138
+ if (!response) {
139
+ return null;
140
+ }
141
+
134
142
  return response
135
143
  .map((tenant) => tenant.subscribedTenantId ?? tenant.tenant)
136
144
  .reduce(async (result, tenantId) => {
@@ -142,7 +150,27 @@ const getAllTenantIds = async () => {
142
150
  }, []);
143
151
  };
144
152
 
153
+ const getAllTenantWithSubdomain = async () => {
154
+ const response = await _getAllTenantBase();
155
+ if (!response) {
156
+ return null;
157
+ }
158
+
159
+ return response.reduce(async (result, row) => {
160
+ const tenantId = row.subscribedTenantId ?? row.tenant;
161
+ result = await result;
162
+ if (await common.isTenantIdValidCb(TenantIdCheckTypes.eventProcessing, tenantId)) {
163
+ result.push({
164
+ ID: tenantId,
165
+ subdomain: row.subscribedSubdomain,
166
+ });
167
+ }
168
+ return result;
169
+ }, []);
170
+ };
171
+
145
172
  module.exports = {
146
173
  executeInNewTransaction,
147
174
  getAllTenantIds,
175
+ getAllTenantWithSubdomain,
148
176
  };
@@ -85,11 +85,22 @@ const _startOtelTrace = async (ctxWithSpan, traceContext, span, fn) => {
85
85
  const _setAttributes = (context, span, attributes) => {
86
86
  span.setAttribute("sap.tenancy.tenant_id", context.tenant);
87
87
  span.setAttribute("sap.correlation_id", context.id);
88
+ _sanitizeAttributes(attributes);
88
89
  for (const attributeKey in attributes) {
89
90
  span.setAttribute(attributeKey, attributes[attributeKey]);
90
91
  }
91
92
  };
92
93
 
94
+ const _sanitizeAttributes = (attributes = {}) => {
95
+ for (const attributeKey in attributes) {
96
+ attributes[attributeKey] =
97
+ typeof attributes[attributeKey] !== "string"
98
+ ? JSON.stringify(attributes[attributeKey])
99
+ : attributes[attributeKey];
100
+ }
101
+ return attributes;
102
+ };
103
+
93
104
  const getCurrentTraceContext = () => {
94
105
  if (!otel) {
95
106
  return null;
package/srv/index.cds ADDED
@@ -0,0 +1 @@
1
+ using from './service';
@@ -0,0 +1,31 @@
1
+ using sap.eventqueue as db from '../../db';
2
+
3
+ @path: 'event-queue/admin'
4
+ @impl: './admin-service.js'
5
+ @requires: 'internal-user'
6
+ service EventQueueAdminService {
7
+
8
+ @readonly
9
+ @cds.persistence.skip
10
+ entity Event as projection on db.Event {
11
+ null as tenant: String,
12
+ null as landscape: String,
13
+ null as space: String,
14
+ *
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
+ }
24
+
25
+ @readonly
26
+ @cds.persistence.skip
27
+ entity Tenant {
28
+ Key ID: String;
29
+ subdomain: String;
30
+ }
31
+ }
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ const cds = require("@sap/cds");
4
+ const cdsHelper = require("../../src/shared/cdsHelper");
5
+ const { EventProcessingStatus } = require("../../src");
6
+ const config = require("../../src/config");
7
+
8
+ module.exports = class AdminService extends cds.ApplicationService {
9
+ async init() {
10
+ const { Event: EventService, Tenant } = this.entities();
11
+ const { Event: EventDb } = cds.db.entities("sap.eventqueue");
12
+ const { landscape, space } = this.getLandscapeAndSpace();
13
+
14
+ this.before("*", (req) => {
15
+ if (!config.enableAdminService) {
16
+ req.reject(403, "Admin service is disabled by configuration");
17
+ }
18
+
19
+ if (req.target.name === Tenant.name) {
20
+ return;
21
+ }
22
+ const headers = Object.assign({}, req.headers, req.req?.headers);
23
+ const tenant = headers["z-id"] ?? req.data.tenant;
24
+
25
+ if (config.isMultiTenancy && tenant == null) {
26
+ req.reject(400, "Missing tenant ID in request header (z-id)");
27
+ }
28
+ req.headers ??= {};
29
+ req.headers["z-id"] = tenant;
30
+ });
31
+
32
+ this.on("READ", EventService, async (req) => {
33
+ const tenant = req.headers["z-id"];
34
+ return await cds.tx({ tenant: tenant }, async (tx) => {
35
+ if (req.query.SELECT.from.ref[0].id) {
36
+ req.query.SELECT.from.ref[0].id = EventDb.name;
37
+ } else {
38
+ req.query.SELECT.from.ref[0] = EventDb.name;
39
+ }
40
+ const events = await tx.run(req.query);
41
+ return events?.map((event) => {
42
+ event.landscape = landscape;
43
+ event.space = space;
44
+ event.tenant = tenant;
45
+ return event;
46
+ });
47
+ });
48
+ });
49
+
50
+ this.on("READ", Tenant, async () => {
51
+ const tenants = await cdsHelper.getAllTenantWithSubdomain();
52
+ return tenants ?? [];
53
+ });
54
+
55
+ this.on("setStatusAndAttempts", async (req) => {
56
+ const tenant = req.headers["z-id"];
57
+ cds.log("eventQueue").info("Restarting processing for event queue");
58
+ const updateData = {};
59
+
60
+ if (Number.isInteger(req.data.attempts)) {
61
+ updateData.attempts = req.data.attempts;
62
+ }
63
+
64
+ if (Object.values(EventProcessingStatus).includes(req.data.status)) {
65
+ updateData.status = req.data.status;
66
+ }
67
+
68
+ if (!Object.keys(updateData).length) {
69
+ return req.reject(400, "No status or attempts provided");
70
+ }
71
+
72
+ await cds.tx({ tenant, headers: { "z-id": tenant } }, async () => {
73
+ await UPDATE.entity(EventDb)
74
+ .set(updateData)
75
+ .where({ ID: req.params[0].ID ?? req.params[0] });
76
+ });
77
+ return await this.send(new cds.Request({ query: req.query, headers: req.headers }));
78
+ });
79
+
80
+ await super.init();
81
+ }
82
+
83
+ getLandscapeAndSpace() {
84
+ const url = cds.requires.db.credentials.url;
85
+ if (!url) {
86
+ return { landscape: "eu10-canary", space: "local-dev" };
87
+ }
88
+ const match = url.match(/https?:\/\/[^.]+\.authentication\.([^.]+)\.hana\.ondemand\.com/);
89
+ const landscape = (match?.[1] ?? "sap") === "sap" ? "eu10-canary" : match?.[1];
90
+ let space = "local-dev";
91
+ try {
92
+ space = JSON.parse(process.env.VCAP_APPLICATION)?.space_name;
93
+ } catch {
94
+ /* empty */
95
+ }
96
+ return { landscape, space };
97
+ }
98
+ };
@@ -0,0 +1 @@
1
+ using from './admin-service';