@cap-js-community/event-queue 1.10.0-beta.0 → 1.10.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js-community/event-queue",
|
|
3
|
-
"version": "1.10.0-beta.
|
|
3
|
+
"version": "1.10.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",
|
|
@@ -243,7 +243,7 @@ class EventQueueProcessorBase {
|
|
|
243
243
|
* This can be useful for e.g. multiple tasks have been scheduled and always the same user should be informed.
|
|
244
244
|
* In this case the events should be clustered together and only one mail should be sent.
|
|
245
245
|
*/
|
|
246
|
-
clusterQueueEntries(queueEntriesWithPayloadMap) {
|
|
246
|
+
async clusterQueueEntries(queueEntriesWithPayloadMap) {
|
|
247
247
|
Object.entries(queueEntriesWithPayloadMap).forEach(([key, { queueEntry, payload }]) => {
|
|
248
248
|
this.addEntryToProcessingMap(key, queueEntry, payload);
|
|
249
249
|
});
|
package/src/config.js
CHANGED
|
@@ -64,6 +64,7 @@ const ALLOWED_EVENT_OPTIONS_AD_HOC = [
|
|
|
64
64
|
"retryFailedAfter",
|
|
65
65
|
"multiInstanceProcessing",
|
|
66
66
|
"kind",
|
|
67
|
+
"timeBucket",
|
|
67
68
|
];
|
|
68
69
|
|
|
69
70
|
const ALLOWED_EVENT_OPTIONS_PERIODIC_EVENT = [
|
|
@@ -630,6 +631,14 @@ class Config {
|
|
|
630
631
|
}
|
|
631
632
|
event.inheritTraceContext = event.inheritTraceContext ?? DEFAULT_INHERIT_TRACE_CONTEXT;
|
|
632
633
|
|
|
634
|
+
if (event.timeBucket) {
|
|
635
|
+
try {
|
|
636
|
+
CronExpressionParser.parse(event.timeBucket);
|
|
637
|
+
} catch {
|
|
638
|
+
throw EventQueueError.cantParseCronExpression(event.type, event.subType, event.timeBucket);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
633
642
|
this.#basicEventValidation(event);
|
|
634
643
|
}
|
|
635
644
|
|
|
@@ -15,34 +15,222 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
15
15
|
this.logger = cds.log(`${COMPONENT_NAME}/${eventSubType}`);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
async getQueueEntriesAndSetToInProgress() {
|
|
19
|
+
const [serviceName] = this.eventSubType.split(".");
|
|
20
|
+
this.__srv = await cds.connect.to(serviceName);
|
|
21
|
+
this.__srvUnboxed = cds.unboxed(this.__srv);
|
|
22
|
+
const { handlers, clusterRelevant, specificClusterRelevant } = this.__srvUnboxed.handlers.on.reduce(
|
|
23
|
+
(result, handler) => {
|
|
24
|
+
if (handler.on.startsWith("clusterQueueEntries")) {
|
|
25
|
+
if (handler.on.split(".").length === 2) {
|
|
26
|
+
result.specificClusterRelevant = true;
|
|
27
|
+
} else {
|
|
28
|
+
result.clusterRelevant = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
result.handlers[handler.on] = handler.on;
|
|
33
|
+
return result;
|
|
34
|
+
},
|
|
35
|
+
{ handlers: {}, clusterRelevant: false, specificClusterRelevant: false }
|
|
36
|
+
);
|
|
37
|
+
this.__onHandlers = handlers;
|
|
38
|
+
this.__genericClusterHandler = clusterRelevant;
|
|
39
|
+
this.__specificClusterHandler = specificClusterRelevant;
|
|
40
|
+
await this.#setContextUser(this.context, config.userId);
|
|
41
|
+
return super.getQueueEntriesAndSetToInProgress();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// NOTE: issue here: if events are not sorted before it might not be unique here:
|
|
45
|
+
// - we have service events
|
|
46
|
+
// - we have action specific events
|
|
47
|
+
// 1. I need to collect all action names
|
|
48
|
+
// 2. I need to check for which actions a handler exits
|
|
49
|
+
// 3. For actions with specific existing handler --> call specific action
|
|
50
|
+
// 4. For all others call generic handler
|
|
51
|
+
// NOTE: OVERALL idea is that the handler returns the cluster map and MUST not call any baseImpl functions!
|
|
52
|
+
// --> structure is a map of { key: { queueEntries: [], payload: {} }
|
|
53
|
+
// TODO: document that clusterQueueEntries is now async!!!
|
|
54
|
+
// TODO: validate that return structure is as expected
|
|
55
|
+
async clusterQueueEntries(queueEntriesWithPayloadMap) {
|
|
56
|
+
if (!this.__genericClusterRelevantAndAvailable && !this.__specificClusterRelevantAndAvailable) {
|
|
57
|
+
return super.clusterQueueEntries(queueEntriesWithPayloadMap);
|
|
58
|
+
}
|
|
59
|
+
const { genericClusterEvents, specificClusterEvents } = this.#clusterByAction(queueEntriesWithPayloadMap);
|
|
60
|
+
if (Object.keys(genericClusterEvents).length) {
|
|
61
|
+
if (!this.__genericClusterRelevantAndAvailable) {
|
|
62
|
+
await super.clusterQueueEntries(genericClusterEvents);
|
|
63
|
+
} else {
|
|
64
|
+
const msg = new cds.Request({
|
|
65
|
+
event: "clusterQueueEntries",
|
|
66
|
+
data: { queueEntriesWithPayloadMap: genericClusterEvents },
|
|
67
|
+
eventQueue: {
|
|
68
|
+
processor: this,
|
|
69
|
+
clusterByPayloadProperty: (propertyName) =>
|
|
70
|
+
EventQueueGenericOutboxHandler.clusterByPayloadProperty(genericClusterEvents, propertyName),
|
|
71
|
+
clusterByEventProperty: (propertyName) =>
|
|
72
|
+
EventQueueGenericOutboxHandler.clusterByEventProperty(genericClusterEvents, propertyName),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const handlerCluster = await this.__srvUnboxed.tx(this.context).send(msg);
|
|
76
|
+
this.#addToProcessingMap(handlerCluster);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const actionName in specificClusterEvents) {
|
|
81
|
+
const msg = new cds.Request({
|
|
82
|
+
event: `clusterQueueEntries.${actionName}`,
|
|
83
|
+
data: { queueEntriesWithPayloadMap: specificClusterEvents[actionName] },
|
|
84
|
+
eventQueue: {
|
|
85
|
+
processor: this,
|
|
86
|
+
clusterByPayloadProperty: (propertyName) =>
|
|
87
|
+
EventQueueGenericOutboxHandler.clusterByPayloadProperty(specificClusterEvents[actionName], propertyName),
|
|
88
|
+
clusterByEventProperty: (propertyName) =>
|
|
89
|
+
EventQueueGenericOutboxHandler.clusterByEventProperty(specificClusterEvents[actionName], propertyName),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const handlerCluster = await this.__srvUnboxed.tx(this.context).send(msg);
|
|
93
|
+
this.#addToProcessingMap(handlerCluster);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static clusterByPayloadProperty(queueEntriesWithPayloadMap, propertyName) {
|
|
98
|
+
return Object.entries(queueEntriesWithPayloadMap).reduce((result, [, { queueEntry, payload }]) => {
|
|
99
|
+
result[payload[propertyName]] ??= {
|
|
100
|
+
queueEntries: [],
|
|
101
|
+
payload,
|
|
102
|
+
};
|
|
103
|
+
result[payload[propertyName]].queueEntries.push(queueEntry);
|
|
104
|
+
return result;
|
|
105
|
+
}, {});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static clusterByEventProperty(queueEntriesWithPayloadMap, propertyName) {
|
|
109
|
+
return Object.entries(queueEntriesWithPayloadMap).reduce((result, [, { queueEntry, payload }]) => {
|
|
110
|
+
result[queueEntry[propertyName]] ??= {
|
|
111
|
+
queueEntries: [],
|
|
112
|
+
payload,
|
|
113
|
+
};
|
|
114
|
+
result[queueEntry[propertyName]].queueEntries.push(queueEntry);
|
|
115
|
+
return result;
|
|
116
|
+
}, {});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#clusterByAction(queueEntriesWithPayloadMap) {
|
|
120
|
+
return Object.entries(queueEntriesWithPayloadMap).reduce(
|
|
121
|
+
(result, [eventId, clusterData]) => {
|
|
122
|
+
const hasSpecificClusterHandler = this.#hasEventSpecificClusterHandler(clusterData.queueEntry);
|
|
123
|
+
if (hasSpecificClusterHandler && this.__specificClusterRelevantAndAvailable) {
|
|
124
|
+
result.specificClusterEvents[clusterData.payload.event] ??= {};
|
|
125
|
+
result.specificClusterEvents[clusterData.payload.event][eventId] = clusterData;
|
|
126
|
+
} else {
|
|
127
|
+
result.genericClusterEvents[eventId] = clusterData;
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
},
|
|
131
|
+
{ genericClusterEvents: {}, specificClusterEvents: {} }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#addToProcessingMap(handlerCluster) {
|
|
136
|
+
for (const clusterKey in handlerCluster) {
|
|
137
|
+
const { payload, queueEntries } = handlerCluster[clusterKey];
|
|
138
|
+
for (const queueEntry of queueEntries) {
|
|
139
|
+
this.addEntryToProcessingMap(clusterKey, queueEntry, payload);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// NOTE: Currently not exposed to CAP service; I don't see any valid use case at this time
|
|
145
|
+
modifyQueueEntry(queueEntry) {
|
|
146
|
+
super.modifyQueueEntry(queueEntry);
|
|
147
|
+
const hasSpecificClusterHandler = this.#hasEventSpecificClusterHandler(queueEntry);
|
|
148
|
+
if (this.__specificClusterHandler && hasSpecificClusterHandler) {
|
|
149
|
+
this.__specificClusterRelevantAndAvailable = true;
|
|
150
|
+
}
|
|
151
|
+
if (this.__genericClusterHandler && !hasSpecificClusterHandler) {
|
|
152
|
+
this.__genericClusterRelevantAndAvailable = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#hasEventSpecificClusterHandler(queueEntry) {
|
|
157
|
+
return !!this.__onHandlers[["clusterQueueEntries", queueEntry.payload.event].join(".")];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async checkEventAndGeneratePayload(queueEntry) {
|
|
161
|
+
const payload = await super.checkEventAndGeneratePayload(queueEntry);
|
|
162
|
+
const { event } = payload;
|
|
163
|
+
const handlerName = this.#checkHandlerExists("checkEventAndGeneratePayload", event);
|
|
164
|
+
if (!handlerName) {
|
|
165
|
+
return payload;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { msg, userId } = this.#buildDispatchData(this.context, payload, {
|
|
169
|
+
queueEntries: [queueEntry],
|
|
170
|
+
});
|
|
171
|
+
msg.event = handlerName;
|
|
172
|
+
await this.#setContextUser(this.context, userId);
|
|
173
|
+
const data = await this.__srvUnboxed.tx(this.context).send(msg);
|
|
174
|
+
if (data) {
|
|
175
|
+
payload.data = data;
|
|
176
|
+
return payload;
|
|
177
|
+
} else {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// simple here as per entry
|
|
183
|
+
async hookForExceededEvents(exceededEvent) {
|
|
184
|
+
return await super.hookForExceededEvents(exceededEvent);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async beforeProcessingEvents() {
|
|
188
|
+
return await super.beforeProcessingEvents();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// maybe async getter on req.data // only for periodic events
|
|
192
|
+
// getLastSuccessfulRunTimestamp
|
|
193
|
+
|
|
194
|
+
#checkHandlerExists(eventQueueFn, event) {
|
|
195
|
+
const specificHandler = this.__onHandlers[[eventQueueFn, event].join(".")];
|
|
196
|
+
if (specificHandler) {
|
|
197
|
+
return specificHandler;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const genericHandler = this.__onHandlers[eventQueueFn];
|
|
201
|
+
return genericHandler ?? null;
|
|
202
|
+
}
|
|
203
|
+
|
|
18
204
|
async processPeriodicEvent(processContext, key, queueEntry) {
|
|
19
|
-
const [
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
205
|
+
const [, action] = this.eventSubType.split(".");
|
|
206
|
+
const msg = new cds.Event({ event: action, eventQueue: { processor: this, key, queueEntries: [queueEntry] } });
|
|
207
|
+
await this.#setContextUser(processContext, config.userId);
|
|
208
|
+
await this.__srvUnboxed.tx(processContext).emit(msg);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#buildDispatchData(context, payload, { key, queueEntries } = {}) {
|
|
212
|
+
const { useEventQueueUser } = this.eventConfig;
|
|
213
|
+
const userId = useEventQueueUser ? config.userId : payload.contextUser;
|
|
214
|
+
const msg = payload._fromSend ? new cds.Request(payload) : new cds.Event(payload);
|
|
215
|
+
const invocationFn = payload._fromSend ? "send" : "emit";
|
|
216
|
+
delete msg._fromSend; // TODO: this changes the source object --> check after multiple invocations
|
|
217
|
+
delete msg.contextUser;
|
|
218
|
+
msg.eventQueue = { processor: this, key, queueEntries, payload };
|
|
219
|
+
return { msg, userId, invocationFn };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async #setContextUser(context, userId) {
|
|
223
|
+
context.user = new cds.User.Privileged({
|
|
224
|
+
id: userId,
|
|
225
|
+
authInfo: await common.getTokenInfo(this.baseContext.tenant),
|
|
25
226
|
});
|
|
26
|
-
processContext._eventQueue = { processor: this, key, queueEntries: [queueEntry] };
|
|
27
|
-
await cds.unboxed(service).tx(processContext)["emit"](msg);
|
|
28
227
|
}
|
|
29
228
|
|
|
30
229
|
async processEvent(processContext, key, queueEntries, payload) {
|
|
31
230
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const userId = useEventQueueUser ? config.userId : payload.contextUser;
|
|
36
|
-
const msg = payload._fromSend ? new cds.Request(payload) : new cds.Event(payload);
|
|
37
|
-
const invocationFn = payload._fromSend ? "send" : "emit";
|
|
38
|
-
delete msg._fromSend;
|
|
39
|
-
delete msg.contextUser;
|
|
40
|
-
processContext.user = new cds.User.Privileged({
|
|
41
|
-
id: userId,
|
|
42
|
-
authInfo: await common.getTokenInfo(processContext.tenant),
|
|
43
|
-
});
|
|
44
|
-
processContext._eventQueue = { processor: this, key, queueEntries, payload };
|
|
45
|
-
const result = await cds.unboxed(service).tx(processContext)[invocationFn](msg);
|
|
231
|
+
const { userId, invocationFn, msg } = this.#buildDispatchData(processContext, payload, { key, queueEntries });
|
|
232
|
+
await this.#setContextUser(processContext, userId);
|
|
233
|
+
const result = await this.__srvUnboxed.tx(processContext)[invocationFn](msg);
|
|
46
234
|
return this.#determineResultStatus(result, queueEntries);
|
|
47
235
|
} catch (err) {
|
|
48
236
|
this.logger.error("error processing outboxed service call", err, {
|
package/src/processEventQueue.js
CHANGED
|
@@ -79,7 +79,7 @@ const processEventQueue = async (context, eventType, eventSubType) => {
|
|
|
79
79
|
await executeInNewTransaction(context, `eventQueue-processing-${eventType}##${eventSubType}`, async (tx) => {
|
|
80
80
|
eventTypeInstance.processEventContext = tx.context;
|
|
81
81
|
try {
|
|
82
|
-
eventTypeInstance.clusterQueueEntries(eventTypeInstance.queueEntriesWithPayloadMap);
|
|
82
|
+
await eventTypeInstance.clusterQueueEntries(eventTypeInstance.queueEntriesWithPayloadMap);
|
|
83
83
|
await processEventMap(eventTypeInstance);
|
|
84
84
|
} catch (err) {
|
|
85
85
|
eventTypeInstance.handleErrorDuringClustering(err);
|
package/src/publishEvent.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const { CronExpressionParser } = require("cron-parser");
|
|
4
|
+
|
|
3
5
|
const config = require("./config");
|
|
4
6
|
const common = require("./shared/common");
|
|
5
7
|
const EventQueueError = require("./EventQueueError");
|
|
@@ -60,6 +62,10 @@ const publishEvent = async (
|
|
|
60
62
|
if (addTraceContext) {
|
|
61
63
|
event.context = JSON.stringify({ traceContext: openTelemetry.getCurrentTraceContext() });
|
|
62
64
|
}
|
|
65
|
+
|
|
66
|
+
if (eventConfig.timeBucket) {
|
|
67
|
+
event.startAfter ??= CronExpressionParser.parse(eventConfig.timeBucket).next().toISOString();
|
|
68
|
+
}
|
|
63
69
|
}
|
|
64
70
|
if (config.insertEventsBeforeCommit && !skipInsertEventsBeforeCommit) {
|
|
65
71
|
_registerHandlerAndAddEvents(tx, events);
|