@cap-js-community/event-queue 1.8.0 → 1.8.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 +7 -7
- package/src/EventQueueError.js +15 -0
- package/src/EventQueueProcessorBase.js +5 -5
- package/src/config.js +10 -0
- package/src/index.d.ts +5 -0
- package/src/processEventQueue.js +7 -6
- package/src/redis/redisPub.js +4 -2
- package/src/runner/runner.js +5 -3
- package/src/runner/runnerHelper.js +1 -1
- package/src/shared/cdsHelper.js +21 -30
- package/src/shared/distributedLock.js +31 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js-community/event-queue",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.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",
|
|
@@ -50,15 +50,15 @@
|
|
|
50
50
|
"yaml": "^2.6.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@cap-js/hana": "^1.
|
|
54
|
-
"@cap-js/sqlite": "^1.7.
|
|
55
|
-
"@sap/cds": "^8.
|
|
56
|
-
"@sap/cds-dk": "^8.
|
|
53
|
+
"@cap-js/hana": "^1.5.2",
|
|
54
|
+
"@cap-js/sqlite": "^1.7.8",
|
|
55
|
+
"@sap/cds": "^8.6.0",
|
|
56
|
+
"@sap/cds-dk": "^8.6.1",
|
|
57
57
|
"eslint": "^8.57.0",
|
|
58
58
|
"eslint-config-prettier": "^9.1.0",
|
|
59
59
|
"eslint-plugin-jest": "^28.6.0",
|
|
60
60
|
"eslint-plugin-node": "^11.1.0",
|
|
61
|
-
"express": "^4.21.
|
|
61
|
+
"express": "^4.21.2",
|
|
62
62
|
"hdb": "^0.19.10",
|
|
63
63
|
"jest": "^29.7.0",
|
|
64
64
|
"prettier": "^2.8.8",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"disableRedis": false
|
|
76
76
|
},
|
|
77
77
|
"[test]": {
|
|
78
|
-
"
|
|
78
|
+
"isEventQueueActive": true,
|
|
79
79
|
"registerAsEventProcessor": false,
|
|
80
80
|
"updatePeriodicEvents": false,
|
|
81
81
|
"insertEventsBeforeCommit": false
|
package/src/EventQueueError.js
CHANGED
|
@@ -24,6 +24,7 @@ const ERROR_CODES = {
|
|
|
24
24
|
NOT_ALLOWED_PRIORITY: "NOT_ALLOWED_PRIORITY",
|
|
25
25
|
APP_NAMES_FORMAT: "APP_NAMES_FORMAT",
|
|
26
26
|
APP_INSTANCES_FORMAT: "APP_INSTANCES_FORMAT",
|
|
27
|
+
MULTI_INSTANCE_PROCESSING_NOT_ALLOWED: "MULTI_INSTANCE_PROCESSING_NOT_ALLOWED",
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
const ERROR_CODES_META = {
|
|
@@ -91,6 +92,9 @@ const ERROR_CODES_META = {
|
|
|
91
92
|
[ERROR_CODES.INTERVAL_AND_CRON]: {
|
|
92
93
|
message: "For periodic events only the cron or interval parameter can be defined!",
|
|
93
94
|
},
|
|
95
|
+
[ERROR_CODES.MULTI_INSTANCE_PROCESSING_NOT_ALLOWED]: {
|
|
96
|
+
message: "The config multiInstanceProcessing is currently only allowed for ad-hoc events and single-tenant-apps.",
|
|
97
|
+
},
|
|
94
98
|
};
|
|
95
99
|
|
|
96
100
|
class EventQueueError extends VError {
|
|
@@ -326,6 +330,17 @@ class EventQueueError extends VError {
|
|
|
326
330
|
);
|
|
327
331
|
}
|
|
328
332
|
|
|
333
|
+
static multiInstanceProcessingNotAllowed(type, subType) {
|
|
334
|
+
const { message } = ERROR_CODES_META[ERROR_CODES.MULTI_INSTANCE_PROCESSING_NOT_ALLOWED];
|
|
335
|
+
return new EventQueueError(
|
|
336
|
+
{
|
|
337
|
+
name: ERROR_CODES.MULTI_INSTANCE_PROCESSING_NOT_ALLOWED,
|
|
338
|
+
info: { type, subType },
|
|
339
|
+
},
|
|
340
|
+
message
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
329
344
|
static isRedisConnectionFailure(err) {
|
|
330
345
|
return err instanceof VError && err.name === ERROR_CODES.REDIS_CREATE_CLIENT;
|
|
331
346
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const cds = require("@sap/cds");
|
|
4
4
|
const cronParser = require("cron-parser");
|
|
5
5
|
|
|
6
|
-
const { executeInNewTransaction
|
|
6
|
+
const { executeInNewTransaction } = require("./shared/cdsHelper");
|
|
7
7
|
const { EventProcessingStatus, TransactionMode } = require("./constants");
|
|
8
8
|
const distributedLock = require("./shared/distributedLock");
|
|
9
9
|
const EventQueueError = require("./EventQueueError");
|
|
@@ -61,8 +61,7 @@ class EventQueueProcessorBase {
|
|
|
61
61
|
this.__parallelEventProcessing = LIMIT_PARALLEL_EVENT_PROCESSING;
|
|
62
62
|
}
|
|
63
63
|
this.#retryFailedAfter = this.#eventConfig.retryFailedAfter ?? DEFAULT_RETRY_AFTER;
|
|
64
|
-
|
|
65
|
-
this.__concurrentEventProcessing = false;
|
|
64
|
+
this.__concurrentEventProcessing = this.#eventConfig.multiInstanceProcessing;
|
|
66
65
|
this.__startTime = this.#eventConfig.startTime ?? new Date();
|
|
67
66
|
this.__retryAttempts = this.#isPeriodic ? 1 : this.#eventConfig.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS;
|
|
68
67
|
this.__selectMaxChunkSize = this.#eventConfig.selectMaxChunkSize ?? SELECT_LIMIT_EVENTS_PER_TICK;
|
|
@@ -786,7 +785,7 @@ class EventQueueProcessorBase {
|
|
|
786
785
|
await executeInNewTransaction(this.context, "error-hookForExceededEvents", async (tx) =>
|
|
787
786
|
this.#persistEventQueueStatusForExceeded(tx, [exceededEvent], EventProcessingStatus.Error)
|
|
788
787
|
);
|
|
789
|
-
|
|
788
|
+
await tx.rollback();
|
|
790
789
|
}
|
|
791
790
|
}
|
|
792
791
|
);
|
|
@@ -918,7 +917,8 @@ class EventQueueProcessorBase {
|
|
|
918
917
|
return await trace(this.baseContext, "acquire-lock", async () => {
|
|
919
918
|
const lockAcquired = await distributedLock.acquireLock(
|
|
920
919
|
this.context,
|
|
921
|
-
[this.#eventType, this.#eventSubType].join("##")
|
|
920
|
+
[this.#eventType, this.#eventSubType].join("##"),
|
|
921
|
+
{ keepTrackOfLock: true }
|
|
922
922
|
);
|
|
923
923
|
if (!lockAcquired) {
|
|
924
924
|
this.logger.debug("no lock available, exit processing", {
|
package/src/config.js
CHANGED
|
@@ -445,6 +445,11 @@ class Config {
|
|
|
445
445
|
if (!event.interval || event.interval <= MIN_INTERVAL_SEC) {
|
|
446
446
|
throw EventQueueError.invalidInterval(event.type, event.subType, event.interval);
|
|
447
447
|
}
|
|
448
|
+
|
|
449
|
+
if (event.multiInstanceProcessing) {
|
|
450
|
+
throw EventQueueError.multiInstanceProcessingNotAllowed(event.type, event.subType);
|
|
451
|
+
}
|
|
452
|
+
|
|
448
453
|
this.#basicEventValidation(event);
|
|
449
454
|
}
|
|
450
455
|
|
|
@@ -453,6 +458,11 @@ class Config {
|
|
|
453
458
|
if (eventMap[key] && !eventMap[key].isPeriodic) {
|
|
454
459
|
throw EventQueueError.duplicateEventRegistration(event.type, event.subType);
|
|
455
460
|
}
|
|
461
|
+
|
|
462
|
+
if (this.isMultiTenancy && event.multiInstanceProcessing) {
|
|
463
|
+
throw EventQueueError.multiInstanceProcessingNotAllowed(event.type, event.subType);
|
|
464
|
+
}
|
|
465
|
+
|
|
456
466
|
this.#basicEventValidation(event);
|
|
457
467
|
}
|
|
458
468
|
|
package/src/index.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export declare const EventProcessingStatus: {
|
|
|
11
11
|
declare type EventProcessingStatusKeysType = keyof typeof EventProcessingStatus;
|
|
12
12
|
export declare type EventProcessingStatusType = (typeof EventProcessingStatus)[EventProcessingStatusKeysType];
|
|
13
13
|
|
|
14
|
+
export declare const TenantIdCheckTypes: {
|
|
15
|
+
getAllTenantIds: "getAllTenantIds";
|
|
16
|
+
getTokenInfo: "getTokenInfo";
|
|
17
|
+
};
|
|
18
|
+
|
|
14
19
|
export declare const TransactionMode: {
|
|
15
20
|
isolated: "isolated";
|
|
16
21
|
alwaysCommit: "alwaysCommit";
|
package/src/processEventQueue.js
CHANGED
|
@@ -8,7 +8,7 @@ const config = require("./config");
|
|
|
8
8
|
const { TransactionMode, EventProcessingStatus } = require("./constants");
|
|
9
9
|
const { limiter } = require("./shared/common");
|
|
10
10
|
|
|
11
|
-
const { executeInNewTransaction
|
|
11
|
+
const { executeInNewTransaction } = require("./shared/cdsHelper");
|
|
12
12
|
const trace = require("./shared/openTelemetry");
|
|
13
13
|
|
|
14
14
|
const COMPONENT_NAME = "/eventQueue/processEventQueue";
|
|
@@ -65,7 +65,7 @@ const processEventQueue = async (context, eventType, eventSubType, startTime = n
|
|
|
65
65
|
eventTypeInstance.handleErrorDuringProcessing(err, queueEntry);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
await tx.rollback();
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
await eventTypeInstance.handleExceededEvents();
|
|
@@ -89,7 +89,7 @@ const processEventQueue = async (context, eventType, eventSubType, startTime = n
|
|
|
89
89
|
eventTypeInstance.shouldRollbackTransaction(key)
|
|
90
90
|
)
|
|
91
91
|
) {
|
|
92
|
-
|
|
92
|
+
await tx.rollback();
|
|
93
93
|
}
|
|
94
94
|
});
|
|
95
95
|
});
|
|
@@ -167,7 +167,8 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
|
|
|
167
167
|
} catch (err) {
|
|
168
168
|
status = EventProcessingStatus.Error;
|
|
169
169
|
eventTypeInstance.handleErrorDuringPeriodicEventProcessing(err, queueEntry);
|
|
170
|
-
|
|
170
|
+
await tx.rollback();
|
|
171
|
+
return;
|
|
171
172
|
} finally {
|
|
172
173
|
eventTypeInstance.endPerformanceTracerPeriodicEvents();
|
|
173
174
|
}
|
|
@@ -175,7 +176,7 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
|
|
|
175
176
|
eventTypeInstance.transactionMode === TransactionMode.alwaysRollback ||
|
|
176
177
|
eventTypeInstance.shouldRollbackTransaction(queueEntry.ID)
|
|
177
178
|
) {
|
|
178
|
-
|
|
179
|
+
await tx.rollback();
|
|
179
180
|
}
|
|
180
181
|
});
|
|
181
182
|
}
|
|
@@ -222,7 +223,7 @@ const processEventMap = async (eventTypeInstance) => {
|
|
|
222
223
|
eventTypeInstance.statusMapContainsError(statusMap) ||
|
|
223
224
|
eventTypeInstance.shouldRollbackTransaction(key)
|
|
224
225
|
) {
|
|
225
|
-
|
|
226
|
+
await tx.rollback();
|
|
226
227
|
}
|
|
227
228
|
}
|
|
228
229
|
);
|
package/src/redis/redisPub.js
CHANGED
|
@@ -5,7 +5,7 @@ const { promisify } = require("util");
|
|
|
5
5
|
const cds = require("@sap/cds");
|
|
6
6
|
|
|
7
7
|
const redis = require("../shared/redis");
|
|
8
|
-
const
|
|
8
|
+
const distributedLock = require("../shared/distributedLock");
|
|
9
9
|
const config = require("../config");
|
|
10
10
|
const common = require("../shared/common");
|
|
11
11
|
const { runEventCombinationForTenant } = require("../runner/runnerHelper");
|
|
@@ -67,7 +67,9 @@ const broadcastEvent = async (tenantId, events, forceBroadcast = false) => {
|
|
|
67
67
|
for (const { type, subType } of events) {
|
|
68
68
|
const eventConfig = config.getEventConfig(type, subType);
|
|
69
69
|
for (let i = 0; i < TRIES_FOR_PUBLISH_PERIODIC_EVENT; i++) {
|
|
70
|
-
const result =
|
|
70
|
+
const result = eventConfig.multiInstanceProcessing
|
|
71
|
+
? false
|
|
72
|
+
: await distributedLock.checkLockExistsAndReturnValue(context, [type, subType].join("##"));
|
|
71
73
|
if (result) {
|
|
72
74
|
logger.debug("skip publish redis event as no lock is available", {
|
|
73
75
|
type,
|
package/src/runner/runner.js
CHANGED
|
@@ -281,9 +281,11 @@ const _singleTenantDb = async () => {
|
|
|
281
281
|
async () => {
|
|
282
282
|
try {
|
|
283
283
|
const lockId = `${label}`;
|
|
284
|
-
const couldAcquireLock =
|
|
285
|
-
|
|
286
|
-
|
|
284
|
+
const couldAcquireLock = eventConfig.multiInstanceProcessing
|
|
285
|
+
? true
|
|
286
|
+
: await distributedLock.acquireLock(context, lockId, {
|
|
287
|
+
expiryTime: eventQueueConfig.runInterval * 0.95,
|
|
288
|
+
});
|
|
287
289
|
if (!couldAcquireLock) {
|
|
288
290
|
return;
|
|
289
291
|
}
|
|
@@ -25,7 +25,7 @@ const runEventCombinationForTenant = async (context, type, subType, { skipWorker
|
|
|
25
25
|
eventConfig.priority,
|
|
26
26
|
AsyncResource.bind(async () => {
|
|
27
27
|
const _exec = async () => {
|
|
28
|
-
if (lockId) {
|
|
28
|
+
if (!eventConfig.multiInstanceProcessing && lockId) {
|
|
29
29
|
const lockAvailable = await distributedLock.acquireLock(context, lockId);
|
|
30
30
|
if (!lockAvailable) {
|
|
31
31
|
return;
|
package/src/shared/cdsHelper.js
CHANGED
|
@@ -62,42 +62,34 @@ async function executeInNewTransaction(context = {}, transactionTag, fn, args, {
|
|
|
62
62
|
} catch {
|
|
63
63
|
/* empty */
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
const txRollback = contextTx.rollback;
|
|
66
|
+
contextTx.rollback = async () => {
|
|
67
|
+
// tx should not be managed here as we did not open the tx
|
|
68
|
+
// change rollback to no opt - closing tx would cause follow-up usage to fail.
|
|
69
|
+
// the process that opened the tx needs to manage it
|
|
70
|
+
};
|
|
71
|
+
await fn(contextTx, ...parameters).finally(() => (contextTx.rollback = txRollback));
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
} catch (err) {
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
);
|
|
84
|
-
}
|
|
75
|
+
if (err instanceof VError) {
|
|
76
|
+
Object.assign(err.jse_info, {
|
|
77
|
+
newTx: info,
|
|
78
|
+
});
|
|
79
|
+
throw err;
|
|
80
|
+
} else {
|
|
81
|
+
throw new VError(
|
|
82
|
+
{
|
|
83
|
+
name: VERROR_CLUSTER_NAME,
|
|
84
|
+
cause: err,
|
|
85
|
+
info,
|
|
86
|
+
},
|
|
87
|
+
"Execution in new transaction failed"
|
|
88
|
+
);
|
|
85
89
|
}
|
|
86
|
-
return false;
|
|
87
90
|
} finally {
|
|
88
91
|
logger.debug("Execution in new transaction finished", info);
|
|
89
92
|
}
|
|
90
|
-
return true;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Error class to be used to force rollback in executionInNewTransaction
|
|
95
|
-
* Error will not be logged, as it assumes that error handling has been done before...
|
|
96
|
-
*/
|
|
97
|
-
class TriggerRollback extends VError {
|
|
98
|
-
constructor() {
|
|
99
|
-
super("Rollback triggered");
|
|
100
|
-
}
|
|
101
93
|
}
|
|
102
94
|
|
|
103
95
|
const getAllTenantIds = async () => {
|
|
@@ -122,6 +114,5 @@ const getAllTenantIds = async () => {
|
|
|
122
114
|
|
|
123
115
|
module.exports = {
|
|
124
116
|
executeInNewTransaction,
|
|
125
|
-
TriggerRollback,
|
|
126
117
|
getAllTenantIds,
|
|
127
118
|
};
|
|
@@ -5,15 +5,18 @@ const config = require("../config");
|
|
|
5
5
|
const cdsHelper = require("./cdsHelper");
|
|
6
6
|
|
|
7
7
|
const KEY_PREFIX = "EVENT_QUEUE";
|
|
8
|
-
|
|
9
8
|
const existingLocks = {};
|
|
10
|
-
|
|
11
9
|
const REDIS_COMMAND_OK = "OK";
|
|
10
|
+
const COMPONENT_NAME = "/eventQueue/distributedLock";
|
|
12
11
|
|
|
13
|
-
const acquireLock = async (
|
|
12
|
+
const acquireLock = async (
|
|
13
|
+
context,
|
|
14
|
+
key,
|
|
15
|
+
{ tenantScoped = true, expiryTime = config.globalTxTimeout, keepTrackOfLock = false } = {}
|
|
16
|
+
) => {
|
|
14
17
|
const fullKey = _generateKey(context, tenantScoped, key);
|
|
15
18
|
if (config.redisEnabled) {
|
|
16
|
-
return await _acquireLockRedis(context, fullKey, expiryTime);
|
|
19
|
+
return await _acquireLockRedis(context, fullKey, expiryTime, { keepTrackOfLock });
|
|
17
20
|
} else {
|
|
18
21
|
return await _acquireLockDB(context, fullKey, expiryTime);
|
|
19
22
|
}
|
|
@@ -23,13 +26,14 @@ const setValueWithExpire = async (
|
|
|
23
26
|
context,
|
|
24
27
|
key,
|
|
25
28
|
value,
|
|
26
|
-
{ tenantScoped = true, expiryTime = config.globalTxTimeout, overrideValue = false } = {}
|
|
29
|
+
{ tenantScoped = true, expiryTime = config.globalTxTimeout, overrideValue = false, keepTrackOfLock = false } = {}
|
|
27
30
|
) => {
|
|
28
31
|
const fullKey = _generateKey(context, tenantScoped, key);
|
|
29
32
|
if (config.redisEnabled) {
|
|
30
33
|
return await _acquireLockRedis(context, fullKey, expiryTime, {
|
|
31
34
|
value,
|
|
32
35
|
overrideValue,
|
|
36
|
+
keepTrackOfLock,
|
|
33
37
|
});
|
|
34
38
|
} else {
|
|
35
39
|
return await _acquireLockDB(context, fullKey, expiryTime, {
|
|
@@ -57,14 +61,19 @@ const checkLockExistsAndReturnValue = async (context, key, { tenantScoped = true
|
|
|
57
61
|
}
|
|
58
62
|
};
|
|
59
63
|
|
|
60
|
-
const _acquireLockRedis = async (
|
|
64
|
+
const _acquireLockRedis = async (
|
|
65
|
+
context,
|
|
66
|
+
fullKey,
|
|
67
|
+
expiryTime,
|
|
68
|
+
{ value = "true", overrideValue = false, keepTrackOfLock } = {}
|
|
69
|
+
) => {
|
|
61
70
|
const client = await redis.createMainClientAndConnect(config.redisOptions);
|
|
62
71
|
const result = await client.set(fullKey, value, {
|
|
63
72
|
PX: Math.round(expiryTime),
|
|
64
73
|
...(overrideValue ? null : { NX: true }),
|
|
65
74
|
});
|
|
66
75
|
const isOk = result === REDIS_COMMAND_OK;
|
|
67
|
-
if (isOk) {
|
|
76
|
+
if (isOk && keepTrackOfLock) {
|
|
68
77
|
existingLocks[fullKey] = 1;
|
|
69
78
|
}
|
|
70
79
|
return isOk;
|
|
@@ -146,7 +155,21 @@ const _generateKey = (context, tenantScoped, key) => {
|
|
|
146
155
|
};
|
|
147
156
|
|
|
148
157
|
const shutdownHandler = async () => {
|
|
149
|
-
|
|
158
|
+
const logger = cds.log(COMPONENT_NAME);
|
|
159
|
+
logger.info("received shutdown event, trying to release all locks", {
|
|
160
|
+
numberOfLocks: Object.keys(existingLocks).length,
|
|
161
|
+
});
|
|
162
|
+
const result = await Promise.allSettled(
|
|
163
|
+
Object.keys(existingLocks).map(async (key) => {
|
|
164
|
+
await _releaseLockRedis(null, key);
|
|
165
|
+
logger.info("lock released", { key });
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
const errors = result.filter((promise) => promise.reason);
|
|
169
|
+
logger.info("releasing locks finished ", {
|
|
170
|
+
numberOfErrors: errors.length,
|
|
171
|
+
...(errors.length && { firstError: errors[0] }),
|
|
172
|
+
});
|
|
150
173
|
};
|
|
151
174
|
|
|
152
175
|
module.exports = {
|