@cap-js-community/event-queue 0.2.2 → 0.2.4
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 +1 -1
- package/src/EventQueueError.js +14 -0
- package/src/EventQueueProcessorBase.js +83 -31
- package/src/config.js +32 -14
- package/src/dbHandler.js +2 -3
- package/src/index.js +1 -1
- package/src/initialize.js +23 -30
- package/src/periodicEvents.js +17 -16
- package/src/processEventQueue.js +59 -61
- package/src/publishEvent.js +3 -4
- package/src/redisPubSub.js +8 -6
- package/src/runner.js +89 -70
- package/src/shared/WorkerQueue.js +69 -28
- package/src/shared/cdsHelper.js +2 -2
- package/src/shared/common.js +1 -72
- package/src/shared/distributedLock.js +15 -21
- package/src/shared/eventScheduler.js +13 -8
|
@@ -2,66 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
const cds = require("@sap/cds");
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const config = require("../config");
|
|
6
|
+
const EventQueueError = require("../EventQueueError");
|
|
6
7
|
|
|
7
8
|
const COMPONENT_NAME = "eventQueue/WorkerQueue";
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const NANO_TO_MS = 1e6;
|
|
10
|
+
const THRESHOLD = {
|
|
11
|
+
INFO: 5 * 1000,
|
|
12
|
+
WARN: 10 * 1000,
|
|
13
|
+
ERROR: 15 * 1000,
|
|
14
|
+
};
|
|
10
15
|
|
|
11
16
|
class WorkerQueue {
|
|
17
|
+
#concurrencyLimit;
|
|
18
|
+
#runningPromises;
|
|
19
|
+
#runningLoad;
|
|
20
|
+
#queue;
|
|
21
|
+
static #instance;
|
|
22
|
+
|
|
12
23
|
constructor(concurrency) {
|
|
13
24
|
if (Number.isNaN(concurrency) || concurrency <= 0) {
|
|
14
|
-
this
|
|
25
|
+
this.#concurrencyLimit = 1;
|
|
15
26
|
} else {
|
|
16
|
-
this
|
|
27
|
+
this.#concurrencyLimit = concurrency;
|
|
17
28
|
}
|
|
18
|
-
this
|
|
19
|
-
this
|
|
29
|
+
this.#runningPromises = [];
|
|
30
|
+
this.#runningLoad = 0;
|
|
31
|
+
this.#queue = [];
|
|
20
32
|
}
|
|
21
33
|
|
|
22
|
-
addToQueue(cb) {
|
|
34
|
+
addToQueue(load, cb) {
|
|
35
|
+
if (load > this.#concurrencyLimit) {
|
|
36
|
+
throw EventQueueError.loadHigherThanLimit(load);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const startTime = process.hrtime.bigint();
|
|
23
40
|
const p = new Promise((resolve, reject) => {
|
|
24
|
-
this.
|
|
41
|
+
this.#queue.push([load, cb, resolve, reject, startTime]);
|
|
25
42
|
});
|
|
26
43
|
this._checkForNext();
|
|
27
44
|
return p;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
|
-
_executeFunction(cb, resolve, reject) {
|
|
47
|
+
_executeFunction(load, cb, resolve, reject, startTime) {
|
|
48
|
+
this.checkAndLogWaitingTime(startTime);
|
|
31
49
|
const promise = Promise.resolve().then(() => cb());
|
|
32
|
-
this.
|
|
50
|
+
this.#runningPromises.push(promise);
|
|
51
|
+
this.#runningLoad = this.#runningLoad + load;
|
|
33
52
|
promise
|
|
34
53
|
.finally(() => {
|
|
35
|
-
this
|
|
54
|
+
this.#runningLoad = this.#runningLoad - load;
|
|
55
|
+
this.#runningPromises.splice(this.#runningPromises.indexOf(promise), 1);
|
|
36
56
|
this._checkForNext();
|
|
37
57
|
})
|
|
38
58
|
.then((...results) => {
|
|
39
59
|
resolve(...results);
|
|
40
60
|
})
|
|
41
61
|
.catch((err) => {
|
|
42
|
-
cds.log(COMPONENT_NAME).error("Error happened in WorkQueue. Errors should be caught before!
|
|
62
|
+
cds.log(COMPONENT_NAME).error("Error happened in WorkQueue. Errors should be caught before!", err);
|
|
43
63
|
reject(err);
|
|
44
64
|
});
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
_checkForNext() {
|
|
48
|
-
|
|
68
|
+
const load = this.#queue[0]?.[0];
|
|
69
|
+
if (!this.#queue.length || this.#runningLoad + load > this.#concurrencyLimit) {
|
|
49
70
|
return;
|
|
50
71
|
}
|
|
51
|
-
const
|
|
52
|
-
this._executeFunction(
|
|
72
|
+
const args = this.#queue.shift();
|
|
73
|
+
this._executeFunction(...args);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get runningPromises() {
|
|
77
|
+
return this.#runningPromises;
|
|
53
78
|
}
|
|
54
|
-
}
|
|
55
79
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
80
|
+
/**
|
|
81
|
+
@return { WorkerQueue }
|
|
82
|
+
**/
|
|
83
|
+
static get instance() {
|
|
84
|
+
if (!WorkerQueue.#instance) {
|
|
85
|
+
WorkerQueue.#instance = new WorkerQueue(config.parallelTenantProcessing);
|
|
61
86
|
}
|
|
62
|
-
return instance;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
return WorkerQueue.#instance;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
checkAndLogWaitingTime(startTime) {
|
|
91
|
+
const diffMs = Math.round(Number(process.hrtime.bigint() - startTime) / NANO_TO_MS);
|
|
92
|
+
let logLevel;
|
|
93
|
+
if (diffMs >= THRESHOLD.ERROR) {
|
|
94
|
+
logLevel = "error";
|
|
95
|
+
} else if (diffMs >= THRESHOLD.WARN) {
|
|
96
|
+
logLevel = "warn";
|
|
97
|
+
} else if (diffMs >= THRESHOLD.INFO) {
|
|
98
|
+
logLevel = "info";
|
|
99
|
+
} else {
|
|
100
|
+
logLevel = "debug";
|
|
101
|
+
}
|
|
102
|
+
cds.log(COMPONENT_NAME)[logLevel]("Waiting time in worker queue", {
|
|
103
|
+
diffMs,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = WorkerQueue;
|
package/src/shared/cdsHelper.js
CHANGED
|
@@ -94,7 +94,7 @@ class TriggerRollback extends VError {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const getSubdomainForTenantId = async (tenantId) => {
|
|
97
|
-
if (!config.
|
|
97
|
+
if (!config.isMultiTenancy) {
|
|
98
98
|
return null;
|
|
99
99
|
}
|
|
100
100
|
if (subdomainCache[tenantId]) {
|
|
@@ -111,7 +111,7 @@ const getSubdomainForTenantId = async (tenantId) => {
|
|
|
111
111
|
};
|
|
112
112
|
|
|
113
113
|
const getAllTenantIds = async () => {
|
|
114
|
-
if (!config.
|
|
114
|
+
if (!config.isMultiTenancy) {
|
|
115
115
|
return null;
|
|
116
116
|
}
|
|
117
117
|
const ssp = await cds.connect.to("cds.xt.SaasProvisioningService");
|
package/src/shared/common.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const crypto = require("crypto");
|
|
4
|
-
|
|
5
|
-
const { floor, abs, min } = Math;
|
|
6
|
-
|
|
7
4
|
const arrayToFlatMap = (array, key = "ID") => {
|
|
8
5
|
return array.reduce((result, element) => {
|
|
9
6
|
result[element[key]] = element;
|
|
@@ -11,74 +8,6 @@ const arrayToFlatMap = (array, key = "ID") => {
|
|
|
11
8
|
}, {});
|
|
12
9
|
};
|
|
13
10
|
|
|
14
|
-
/**
|
|
15
|
-
* Establish a "Funnel" instance to limit how much
|
|
16
|
-
* load can be processed in parallel. This is somewhat
|
|
17
|
-
* similar to the limiter function however it has some
|
|
18
|
-
* distinctintly different features. The Funnel will
|
|
19
|
-
* not know in advance which functions and how many
|
|
20
|
-
* loads it will have to process.
|
|
21
|
-
*/
|
|
22
|
-
class Funnel {
|
|
23
|
-
/**
|
|
24
|
-
* Create a funnel with specified capacity
|
|
25
|
-
* @param capacity - the capacity of the funnel (integer, sign will be ignored)
|
|
26
|
-
*/
|
|
27
|
-
constructor(capacity = 100) {
|
|
28
|
-
this.runningPromises = [];
|
|
29
|
-
this.capacity = floor(abs(capacity));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Asynchronously run a function that will put a specified load to the funnel.
|
|
34
|
-
* The total amount of load of all running functions shall not
|
|
35
|
-
* exceed the capacity of the funnel. If the desired load exceeds the capacity
|
|
36
|
-
* the funnel will wait until sufficient capacity is available.
|
|
37
|
-
* If a function requires a load >= capacity, then it will run
|
|
38
|
-
* exclusively.
|
|
39
|
-
* @param load - the load (integer, sign will be ignored)
|
|
40
|
-
* @param f
|
|
41
|
-
* @param args
|
|
42
|
-
* @return {Promise<unknown>}
|
|
43
|
-
*/
|
|
44
|
-
async run(load, f, ...args) {
|
|
45
|
-
load = min(floor(abs(load)), Number.MAX_SAFE_INTEGER);
|
|
46
|
-
|
|
47
|
-
// wait for sufficient capacity
|
|
48
|
-
while (this.capacity < load && this.runningPromises.length > 0) {
|
|
49
|
-
try {
|
|
50
|
-
await Promise.race(this.runningPromises);
|
|
51
|
-
} catch {
|
|
52
|
-
// Yes, we must ignore exceptions here. The
|
|
53
|
-
// caller expects exceptions from f and no
|
|
54
|
-
// exceptions from other workloads.
|
|
55
|
-
// Other exceptions must be handled by the
|
|
56
|
-
// other callers. See (*) below.
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// map function call to promise
|
|
61
|
-
const p = f.constructor.name === "AsyncFunction" ? f(...args) : Promise.resolve().then(() => f(...args));
|
|
62
|
-
|
|
63
|
-
// create promise for book keeping
|
|
64
|
-
const workload = p.finally(() => {
|
|
65
|
-
// remove workload
|
|
66
|
-
this.runningPromises.splice(this.runningPromises.indexOf(workload), 1);
|
|
67
|
-
// and reclaim its capacity
|
|
68
|
-
this.capacity += load;
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// claim the capacity and schedule workload
|
|
72
|
-
this.capacity -= load;
|
|
73
|
-
this.runningPromises.push(workload);
|
|
74
|
-
|
|
75
|
-
// make the caller wait for the workload
|
|
76
|
-
// this also establish the seemingly missing
|
|
77
|
-
// exception handling. See (*) above.
|
|
78
|
-
return workload;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
11
|
/**
|
|
83
12
|
* Defines a promise that resolves when all payloads are processed by the iterator, but limits
|
|
84
13
|
* the number concurrent executions.
|
|
@@ -132,4 +61,4 @@ const processChunkedSync = (inputs, chunkSize, chunkHandler) => {
|
|
|
132
61
|
|
|
133
62
|
const hashStringTo32Bit = (value) => crypto.createHash("sha256").update(String(value)).digest("base64").slice(0, 32);
|
|
134
63
|
|
|
135
|
-
module.exports = { arrayToFlatMap,
|
|
64
|
+
module.exports = { arrayToFlatMap, limiter, isValidDate, processChunkedSync, hashStringTo32Bit };
|
|
@@ -3,15 +3,12 @@
|
|
|
3
3
|
const redis = require("./redis");
|
|
4
4
|
const config = require("../config");
|
|
5
5
|
const cdsHelper = require("./cdsHelper");
|
|
6
|
-
const { getConfigInstance } = require("../config");
|
|
7
6
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{ tenantScoped = true, expiryTime = config.getConfigInstance().globalTxTimeout } = {}
|
|
12
|
-
) => {
|
|
7
|
+
const KEY_PREFIX = "EVENT_QUEUE";
|
|
8
|
+
|
|
9
|
+
const acquireLock = async (context, key, { tenantScoped = true, expiryTime = config.globalTxTimeout } = {}) => {
|
|
13
10
|
const fullKey = _generateKey(context, tenantScoped, key);
|
|
14
|
-
if (config.
|
|
11
|
+
if (config.redisEnabled) {
|
|
15
12
|
return await _acquireLockRedis(context, fullKey, expiryTime);
|
|
16
13
|
} else {
|
|
17
14
|
return await _acquireLockDB(context, fullKey, expiryTime);
|
|
@@ -22,10 +19,10 @@ const setValueWithExpire = async (
|
|
|
22
19
|
context,
|
|
23
20
|
key,
|
|
24
21
|
value,
|
|
25
|
-
{ tenantScoped = true, expiryTime = config.
|
|
22
|
+
{ tenantScoped = true, expiryTime = config.globalTxTimeout, overrideValue = false } = {}
|
|
26
23
|
) => {
|
|
27
24
|
const fullKey = _generateKey(context, tenantScoped, key);
|
|
28
|
-
if (config.
|
|
25
|
+
if (config.redisEnabled) {
|
|
29
26
|
return await _acquireLockRedis(context, fullKey, expiryTime, {
|
|
30
27
|
value,
|
|
31
28
|
overrideValue,
|
|
@@ -40,7 +37,7 @@ const setValueWithExpire = async (
|
|
|
40
37
|
|
|
41
38
|
const releaseLock = async (context, key, { tenantScoped = true } = {}) => {
|
|
42
39
|
const fullKey = _generateKey(context, tenantScoped, key);
|
|
43
|
-
if (config.
|
|
40
|
+
if (config.redisEnabled) {
|
|
44
41
|
return await _releaseLockRedis(context, fullKey);
|
|
45
42
|
} else {
|
|
46
43
|
return await _releaseLockDb(context, fullKey);
|
|
@@ -49,7 +46,7 @@ const releaseLock = async (context, key, { tenantScoped = true } = {}) => {
|
|
|
49
46
|
|
|
50
47
|
const checkLockExistsAndReturnValue = async (context, key, { tenantScoped = true } = {}) => {
|
|
51
48
|
const fullKey = _generateKey(context, tenantScoped, key);
|
|
52
|
-
if (config.
|
|
49
|
+
if (config.redisEnabled) {
|
|
53
50
|
return await _checkLockExistsRedis(context, fullKey);
|
|
54
51
|
} else {
|
|
55
52
|
return await _checkLockExistsDb(context, fullKey);
|
|
@@ -72,9 +69,8 @@ const _checkLockExistsRedis = async (context, fullKey) => {
|
|
|
72
69
|
|
|
73
70
|
const _checkLockExistsDb = async (context, fullKey) => {
|
|
74
71
|
let result;
|
|
75
|
-
const configInstance = getConfigInstance();
|
|
76
72
|
await cdsHelper.executeInNewTransaction(context, "distributedLock-checkExists", async (tx) => {
|
|
77
|
-
result = await tx.run(SELECT.one.from(
|
|
73
|
+
result = await tx.run(SELECT.one.from(config.tableNameEventLock).where("code =", fullKey));
|
|
78
74
|
});
|
|
79
75
|
return result?.value;
|
|
80
76
|
};
|
|
@@ -85,19 +81,17 @@ const _releaseLockRedis = async (context, fullKey) => {
|
|
|
85
81
|
};
|
|
86
82
|
|
|
87
83
|
const _releaseLockDb = async (context, fullKey) => {
|
|
88
|
-
const configInstance = getConfigInstance();
|
|
89
84
|
await cdsHelper.executeInNewTransaction(context, "distributedLock-release", async (tx) => {
|
|
90
|
-
await tx.run(DELETE.from(
|
|
85
|
+
await tx.run(DELETE.from(config.tableNameEventLock).where("code =", fullKey));
|
|
91
86
|
});
|
|
92
87
|
};
|
|
93
88
|
|
|
94
89
|
const _acquireLockDB = async (context, fullKey, expiryTime, { value = "true", overrideValue = false } = {}) => {
|
|
95
90
|
let result;
|
|
96
|
-
const configInstance = getConfigInstance();
|
|
97
91
|
await cdsHelper.executeInNewTransaction(context, "distributedLock-acquire", async (tx) => {
|
|
98
92
|
try {
|
|
99
93
|
await tx.run(
|
|
100
|
-
INSERT.into(
|
|
94
|
+
INSERT.into(config.tableNameEventLock).entries({
|
|
101
95
|
code: fullKey,
|
|
102
96
|
value,
|
|
103
97
|
})
|
|
@@ -109,14 +103,14 @@ const _acquireLockDB = async (context, fullKey, expiryTime, { value = "true", ov
|
|
|
109
103
|
if (!overrideValue) {
|
|
110
104
|
currentEntry = await tx.run(
|
|
111
105
|
SELECT.one
|
|
112
|
-
.from(
|
|
113
|
-
.forUpdate({ wait: config.
|
|
106
|
+
.from(config.tableNameEventLock)
|
|
107
|
+
.forUpdate({ wait: config.forUpdateTimeout })
|
|
114
108
|
.where("code =", fullKey)
|
|
115
109
|
);
|
|
116
110
|
}
|
|
117
111
|
if (overrideValue || (currentEntry && new Date(currentEntry.createdAt).getTime() + expiryTime <= Date.now())) {
|
|
118
112
|
await tx.run(
|
|
119
|
-
UPDATE.entity(
|
|
113
|
+
UPDATE.entity(config.tableNameEventLock)
|
|
120
114
|
.set({
|
|
121
115
|
createdAt: new Date().toISOString(),
|
|
122
116
|
value,
|
|
@@ -136,7 +130,7 @@ const _generateKey = (context, tenantScoped, key) => {
|
|
|
136
130
|
const keyParts = [];
|
|
137
131
|
tenantScoped && keyParts.push(context.tenant);
|
|
138
132
|
keyParts.push(key);
|
|
139
|
-
return keyParts.join("##")
|
|
133
|
+
return `${KEY_PREFIX}_${keyParts.join("##")}`;
|
|
140
134
|
};
|
|
141
135
|
|
|
142
136
|
module.exports = {
|
|
@@ -13,11 +13,8 @@ class EventScheduler {
|
|
|
13
13
|
constructor() {}
|
|
14
14
|
|
|
15
15
|
scheduleEvent(tenantId, type, subType, startAfter) {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const scheduleWithoutDelay = configInstance.isPeriodicEvent(type, subType) && eventConfig.interval < 30 * 1000;
|
|
19
|
-
const roundUpDate = scheduleWithoutDelay ? startAfter : this.calculateFutureTime(startAfter, 10);
|
|
20
|
-
const key = [tenantId, type, subType, roundUpDate.toISOString()].join("##");
|
|
16
|
+
const { date, relative } = this.calculateOffset(type, subType, startAfter);
|
|
17
|
+
const key = [tenantId, type, subType, date.toISOString()].join("##");
|
|
21
18
|
if (this.#scheduledEvents[key]) {
|
|
22
19
|
return; // event combination already scheduled
|
|
23
20
|
}
|
|
@@ -25,7 +22,7 @@ class EventScheduler {
|
|
|
25
22
|
cds.log(COMPONENT_NAME).info("scheduling event queue run for delayed event", {
|
|
26
23
|
type,
|
|
27
24
|
subType,
|
|
28
|
-
delaySeconds: (
|
|
25
|
+
delaySeconds: (date.getTime() - Date.now()) / 1000,
|
|
29
26
|
});
|
|
30
27
|
setTimeout(() => {
|
|
31
28
|
delete this.#scheduledEvents[key];
|
|
@@ -34,10 +31,18 @@ class EventScheduler {
|
|
|
34
31
|
tenantId,
|
|
35
32
|
type,
|
|
36
33
|
subType,
|
|
37
|
-
scheduledFor:
|
|
34
|
+
scheduledFor: date.toISOString(),
|
|
38
35
|
});
|
|
39
36
|
});
|
|
40
|
-
},
|
|
37
|
+
}, relative).unref();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
calculateOffset(type, subType, startAfter) {
|
|
41
|
+
const eventConfig = config.getEventConfig(type, subType);
|
|
42
|
+
const scheduleWithoutDelay = config.isPeriodicEvent(type, subType) && eventConfig.interval < 30 * 1000;
|
|
43
|
+
const date = scheduleWithoutDelay ? startAfter : this.calculateFutureTime(startAfter, 10);
|
|
44
|
+
|
|
45
|
+
return { date, relative: date.getTime() - Date.now() };
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
calculateFutureTime(date, seoncds) {
|