@cap-js-community/event-queue 1.10.3 → 1.11.0-beta.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/index.cds +1 -0
- package/package.json +5 -2
- package/src/EventQueueProcessorBase.js +6 -1
- package/src/config.js +16 -4
- package/src/index.d.ts +2 -0
- package/src/initialize.js +33 -12
- package/src/processEventQueue.js +1 -1
- package/src/runner/runner.js +3 -2
- package/src/shared/cdsHelper.js +30 -2
- package/src/shared/openTelemetry.js +11 -0
- package/srv/index.cds +1 -0
- package/srv/service/admin-service.cds +31 -0
- package/srv/service/admin-service.js +98 -0
- package/srv/service/index.cds +1 -0
package/index.cds
CHANGED
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js-community/event-queue",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0-beta.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",
|
|
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.
|
|
51
|
+
"cron-parser": "^5.2.0",
|
|
49
52
|
"redis": "^4.7.0",
|
|
50
53
|
"verror": "^1.10.1",
|
|
51
54
|
"yaml": "^2.7.1"
|
|
@@ -174,7 +174,12 @@ class EventQueueProcessorBase {
|
|
|
174
174
|
eventSubType: this.#eventSubType,
|
|
175
175
|
iterationCounter,
|
|
176
176
|
});
|
|
177
|
-
this.#eventSchedulerInstance.scheduleEvent(
|
|
177
|
+
this.#eventSchedulerInstance.scheduleEvent(
|
|
178
|
+
this.__context.tenant,
|
|
179
|
+
this.#eventType,
|
|
180
|
+
this.#eventSubType,
|
|
181
|
+
new Date(Date.now() + 5 * 1000) // add some offset to make sure all locks are released
|
|
182
|
+
);
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
logStartMessage() {
|
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);
|
|
@@ -638,18 +639,17 @@ class Config {
|
|
|
638
639
|
let cron;
|
|
639
640
|
|
|
640
641
|
// NOTE: logic is as follows:
|
|
641
|
-
// - if event.utc is true --> always use UTC
|
|
642
|
+
// - if event.utc is true --> always use UTC (default is false)
|
|
642
643
|
// - if event.useCronTimezone is false OR event.cronTimezone is not defined --> use UTC as well
|
|
643
644
|
// - if event.utc is not true AND event.cronTimezone is set AND event.useCronTimezone is NOT set to false use event.cronTimezone
|
|
644
645
|
event.utc = event.utc ?? UTC_DEFAULT;
|
|
645
|
-
|
|
646
|
-
if (!event.cronTimezone) {
|
|
646
|
+
if (!this.cronTimezone) {
|
|
647
647
|
event.useCronTimezone = false;
|
|
648
648
|
} else {
|
|
649
649
|
event.useCronTimezone = event.useCronTimezone ?? USE_CRON_TZ_DEFAULT;
|
|
650
650
|
}
|
|
651
651
|
|
|
652
|
-
event.tz = event.utc || !event.useCronTimezone ? "UTC" :
|
|
652
|
+
event.tz = event.utc || !event.useCronTimezone ? "UTC" : this.cronTimezone;
|
|
653
653
|
|
|
654
654
|
try {
|
|
655
655
|
cron = CronExpressionParser.parse(event.cron);
|
|
@@ -972,6 +972,18 @@ class Config {
|
|
|
972
972
|
return this.#eventMap;
|
|
973
973
|
}
|
|
974
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
|
+
|
|
975
987
|
/**
|
|
976
988
|
@return { Config }
|
|
977
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));
|
|
@@ -182,6 +183,14 @@ const monkeyPatchCAPOutbox = () => {
|
|
|
182
183
|
get: () => eventQueueAsOutbox.unboxed,
|
|
183
184
|
configurable: true,
|
|
184
185
|
});
|
|
186
|
+
Object.defineProperty(cds, "queued", {
|
|
187
|
+
get: () => eventQueueAsOutbox.outboxed,
|
|
188
|
+
configurable: true,
|
|
189
|
+
});
|
|
190
|
+
Object.defineProperty(cds, "unqueued", {
|
|
191
|
+
get: () => eventQueueAsOutbox.unboxed,
|
|
192
|
+
configurable: true,
|
|
193
|
+
});
|
|
185
194
|
}
|
|
186
195
|
};
|
|
187
196
|
|
|
@@ -193,34 +202,34 @@ const mixConfigVarsWithEnv = (options) => {
|
|
|
193
202
|
};
|
|
194
203
|
|
|
195
204
|
const registerCdsShutdown = () => {
|
|
196
|
-
|
|
197
|
-
if (isTestProfile || !config.redisEnabled) {
|
|
205
|
+
if (!config.developmentMode) {
|
|
198
206
|
return;
|
|
199
207
|
}
|
|
200
208
|
cds.on("shutdown", async () => {
|
|
201
209
|
return await new Promise((resolve) => {
|
|
202
|
-
|
|
210
|
+
let timeoutRef;
|
|
211
|
+
timeoutRef = setTimeout(() => {
|
|
203
212
|
clearTimeout(timeoutRef);
|
|
204
213
|
cds.log(COMPONENT).info("shutdown timeout reached - some locks might not have been released!");
|
|
205
214
|
resolve();
|
|
206
215
|
}, TIMEOUT_SHUTDOWN);
|
|
207
|
-
distributedLock.shutdownHandler().then(() =>
|
|
208
|
-
Promise.allSettled(
|
|
216
|
+
distributedLock.shutdownHandler().then(() => {
|
|
217
|
+
Promise.allSettled(
|
|
218
|
+
config.redisEnabled ? [redis.closeMainClient(), redis.closeSubscribeClient()] : [Promise.resolve()]
|
|
219
|
+
).then((result) => {
|
|
209
220
|
clearTimeout(timeoutRef);
|
|
210
221
|
resolve(result);
|
|
211
|
-
})
|
|
212
|
-
);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
213
224
|
});
|
|
214
225
|
});
|
|
215
226
|
};
|
|
216
227
|
|
|
217
228
|
const registerCleanupForDevDb = async () => {
|
|
218
|
-
|
|
219
|
-
if (!profile || process.env.NODE_ENV === "production") {
|
|
229
|
+
if (!config.developmentMode) {
|
|
220
230
|
return;
|
|
221
231
|
}
|
|
222
|
-
|
|
223
|
-
const tenantIds = await getAllTenantIds();
|
|
232
|
+
const tenantIds = config.isMultiTenancy ? await getAllTenantIds() : [null];
|
|
224
233
|
for (const tenantId of tenantIds) {
|
|
225
234
|
await cds.tx({ tenant: tenantId }, async (tx) => {
|
|
226
235
|
await tx.run(DELETE.from(config.tableNameEventLock));
|
|
@@ -252,6 +261,18 @@ const _registerUnsubscribe = () => {
|
|
|
252
261
|
});
|
|
253
262
|
};
|
|
254
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
|
+
|
|
255
276
|
module.exports = {
|
|
256
277
|
initialize,
|
|
257
278
|
};
|
package/src/processEventQueue.js
CHANGED
|
@@ -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
|
|
package/src/runner/runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/shared/cdsHelper.js
CHANGED
|
@@ -116,7 +116,7 @@ async function executeInNewTransaction(context = {}, transactionTag, fn, args, {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
const
|
|
119
|
+
const _getAllTenantBase = async () => {
|
|
120
120
|
if (!config.isMultiTenancy) {
|
|
121
121
|
return null;
|
|
122
122
|
}
|
|
@@ -130,7 +130,15 @@ const getAllTenantIds = async () => {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
const ssp = await cds.connect.to("cds.xt.SaasProvisioningService");
|
|
133
|
-
|
|
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("*", async (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';
|