@cap-js-community/event-queue 0.1.49

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.
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+
3
+ const CUSTOM_FIELD_EXECUTION_TIME = "response_time_ms";
4
+ const CUSTOM_FIELD_QUANTITY = "quantity";
5
+
6
+ class PerformanceTracer {
7
+ /**
8
+ * The constructor of the performance tracer
9
+ * @param {Object} logger logger
10
+ * @param {string} name name of the performance trace
11
+ * @param {Object} options An options object with additional properties (optional)
12
+ * @param {string} options.level The level to be used for logging
13
+ * @param {string} options.summary The summary to be used for logging
14
+ * @param {Object} options.startMessage Writes a log record with the provided details when starting the action
15
+ */
16
+ constructor(logger, name, options = {}) {
17
+ this.__start = new Date();
18
+ this.__logger = logger;
19
+ this.__name = name;
20
+ options.startMessage &&
21
+ logger.info("Performance measurement started", {
22
+ name: name,
23
+ ...options.startMessage,
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Ends the performance trace
29
+ * @param {Object} options An options object with additional properties (optional)
30
+ * @param {Number} options.quantity A case-specific quantity such as a node count that influences the execution time (optional)
31
+ * @param {Number} options.threshold Only write the log above verbose level, if threshold in ms. is met (optional)
32
+ * @param {Number} options.additionalQuantityThreshold Value multiplied with quantity and added to the threshold (optional)
33
+ */
34
+ endPerformanceTrace(...args) {
35
+ let options = {};
36
+ //determine, if an options object was provided as first argument
37
+ if (
38
+ typeof args?.[0] === "object" &&
39
+ (args[0].quantity >= 0 ||
40
+ args[0].threshold > 0 ||
41
+ args[0].additionalQuantityThreshold > 0)
42
+ ) {
43
+ options = args.shift();
44
+ }
45
+ const currentTime = new Date();
46
+ const executionTime = currentTime - this.__start;
47
+ this.__start = currentTime;
48
+ const isBelowThreshold =
49
+ options &&
50
+ options.threshold &&
51
+ executionTime <
52
+ options.threshold +
53
+ (options.additionalQuantityThreshold > 0 && options.quantity > 0
54
+ ? options.additionalQuantityThreshold * options.quantity
55
+ : 0);
56
+
57
+ if (isBelowThreshold) {
58
+ return;
59
+ }
60
+
61
+ const customFields = {
62
+ [CUSTOM_FIELD_EXECUTION_TIME]: executionTime,
63
+ [CUSTOM_FIELD_QUANTITY]: options.quantity,
64
+ };
65
+
66
+ this.__logger.info("Performance measurement executed", {
67
+ name: this.__name,
68
+ milliseconds: executionTime,
69
+ customFields,
70
+ });
71
+ }
72
+ }
73
+
74
+ module.exports = PerformanceTracer;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+
3
+ const cds = require("@sap/cds");
4
+
5
+ const { getConfigInstance } = require("../config");
6
+
7
+ const COMPONENT_NAME = "eventQueue/WorkerQueue";
8
+
9
+ let instance = null;
10
+
11
+ class WorkerQueue {
12
+ constructor(concurrency) {
13
+ if (Number.isNaN(concurrency) || concurrency <= 0) {
14
+ this.__concurrencyLimit = 1;
15
+ } else {
16
+ this.__concurrencyLimit = concurrency;
17
+ }
18
+ this.__runningPromises = [];
19
+ this.__queue = [];
20
+ }
21
+
22
+ addToQueue(cb) {
23
+ const p = new Promise((resolve, reject) => {
24
+ this.__queue.push([cb, resolve, reject]);
25
+ });
26
+ this._checkForNext();
27
+ return p;
28
+ }
29
+
30
+ _executeFunction(cb, resolve, reject) {
31
+ const promise = Promise.resolve().then(() => cb());
32
+ this.__runningPromises.push(promise);
33
+ promise
34
+ .finally(() => {
35
+ this.__runningPromises.splice(
36
+ this.__runningPromises.indexOf(promise),
37
+ 1
38
+ );
39
+ this._checkForNext();
40
+ })
41
+ .then((...results) => {
42
+ resolve(...results);
43
+ })
44
+ .catch((err) => {
45
+ cds
46
+ .log(COMPONENT_NAME)
47
+ .error(
48
+ "Error happened in WorkQueue. Errors should be caught before! Error:",
49
+ err
50
+ );
51
+ reject(err);
52
+ });
53
+ }
54
+
55
+ _checkForNext() {
56
+ if (
57
+ !this.__queue.length ||
58
+ this.__runningPromises.length >= this.__concurrencyLimit
59
+ ) {
60
+ return;
61
+ }
62
+ const [cb, resolve, reject] = this.__queue.shift();
63
+ this._executeFunction(cb, resolve, reject);
64
+ }
65
+ }
66
+
67
+ module.exports = {
68
+ getWorkerPoolInstance: () => {
69
+ if (!instance) {
70
+ const configInstance = getConfigInstance();
71
+ instance = new WorkerQueue(configInstance.parallelTenantProcessing);
72
+ }
73
+ return instance;
74
+ },
75
+ _: {
76
+ WorkerQueue,
77
+ },
78
+ };
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+
3
+ const VError = require("verror");
4
+ const cds = require("@sap/cds");
5
+
6
+ const config = require("../config");
7
+
8
+ const subdomainCache = {};
9
+
10
+ const VERROR_CLUSTER_NAME = "ExecuteInNewTransactionError";
11
+ const COMPONENT_NAME = "eventQueue/cdsHelper";
12
+
13
+ /**
14
+ * Execute logic in a new managed CDS transaction context, auto-handling commit, rollback and error/exception situations.
15
+ * Includes logging of start, end and error situation with additional info object and unique transaction id (txId)
16
+ * @param context {object} Current CDS request context
17
+ * @param transactionTag A tag identifying the transaction
18
+ * @param fn {function} Callback function (logic) to be executed in context of new managed CDS transaction
19
+ * @param args {array|object|any} Array of function arguments passed to callback function as spread arguments. Object or primitive types are auto-normalized to array.
20
+ * @param info {object} Additional information object attached to logging
21
+ * @returns {Promise<boolean>} Promise resolving to true if everything worked fine / false if an error occurred
22
+ */
23
+ async function executeInNewTransaction(
24
+ context = {},
25
+ transactionTag,
26
+ fn,
27
+ args,
28
+ { info = {} } = {}
29
+ ) {
30
+ const parameters = Array.isArray(args) ? args : [args];
31
+ const logger = cds.log(COMPONENT_NAME);
32
+ try {
33
+ if (cds.db.kind === "hana") {
34
+ await cds.tx(
35
+ {
36
+ id: context.id,
37
+ tenant: context.tenant,
38
+ locale: context.locale,
39
+ user: context.user,
40
+ headers: context.headers,
41
+ http: context.http,
42
+ },
43
+ async (tx) => {
44
+ tx.context._ = context._ ?? {};
45
+ return await fn(tx, ...parameters);
46
+ }
47
+ );
48
+ } else {
49
+ const contextTx = cds.tx(context);
50
+ const contextTxState = contextTx.ready;
51
+ if (
52
+ !contextTxState ||
53
+ ["committed", "rolled back"].includes(contextTxState)
54
+ ) {
55
+ await cds.tx(
56
+ {
57
+ id: context.id,
58
+ tenant: context.tenant,
59
+ locale: context.locale,
60
+ user: context.user,
61
+ headers: context.headers,
62
+ http: context.http,
63
+ },
64
+ async (tx) => fn(tx, ...parameters)
65
+ );
66
+ } else {
67
+ await fn(contextTx, ...parameters);
68
+ }
69
+ }
70
+ } catch (err) {
71
+ if (!(err instanceof TriggerRollback)) {
72
+ if (err instanceof VError) {
73
+ Object.assign(err.jse_info, {
74
+ newTx: info,
75
+ });
76
+ throw err;
77
+ } else {
78
+ throw new VError(
79
+ {
80
+ name: VERROR_CLUSTER_NAME,
81
+ cause: err,
82
+ info,
83
+ },
84
+ "Execution in new transaction failed"
85
+ );
86
+ }
87
+ }
88
+ return false;
89
+ } finally {
90
+ logger.debug("Execution in new transaction finished", info);
91
+ }
92
+ return true;
93
+ }
94
+
95
+ /**
96
+ * Error class to be used to force rollback in executionInNewTransaction
97
+ * Error will not be logged, as it assumes that error handling has been done before...
98
+ */
99
+ class TriggerRollback extends VError {
100
+ constructor() {
101
+ super("Rollback triggered");
102
+ }
103
+ }
104
+
105
+ const getSubdomainForTenantId = async (tenantId) => {
106
+ if (!config.getConfigInstance().isMultiTenancy) {
107
+ return null;
108
+ }
109
+ if (subdomainCache[tenantId]) {
110
+ return subdomainCache[tenantId];
111
+ }
112
+ try {
113
+ const ssp = await cds.connect.to("cds.xt.SaasProvisioningService");
114
+ const response = await ssp.get("/tenant", { subscribedTenantId: tenantId });
115
+ subdomainCache[tenantId] = response.subscribedSubdomain;
116
+ return response.subscribedSubdomain;
117
+ } catch (err) {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ const getAllTenantIds = async () => {
123
+ if (!config.getConfigInstance().isMultiTenancy) {
124
+ return null;
125
+ }
126
+ const ssp = await cds.connect.to("cds.xt.SaasProvisioningService");
127
+ const response = await ssp.get("/tenant");
128
+ return response.map((tenant) => tenant.subscribedTenantId ?? tenant.tenant);
129
+ };
130
+
131
+ module.exports = {
132
+ executeInNewTransaction,
133
+ TriggerRollback,
134
+ getSubdomainForTenantId,
135
+ getAllTenantIds,
136
+ };
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+
3
+ const { floor, abs, min } = Math;
4
+
5
+ const arrayToFlatMap = (array, key = "ID") => {
6
+ return array.reduce((result, element) => {
7
+ result[element[key]] = element;
8
+ return result;
9
+ }, {});
10
+ };
11
+
12
+ /**
13
+ * Establish a "Funnel" instance to limit how much
14
+ * load can be processed in parallel. This is somewhat
15
+ * similar to the limiter function however it has some
16
+ * distinctintly different features. The Funnel will
17
+ * not know in advance which functions and how many
18
+ * loads it will have to process.
19
+ */
20
+ class Funnel {
21
+ /**
22
+ * Create a funnel with specified capacity
23
+ * @param capacity - the capacity of the funnel (integer, sign will be ignored)
24
+ */
25
+ constructor(capacity = 100) {
26
+ this.runningPromises = [];
27
+ this.capacity = floor(abs(capacity));
28
+ }
29
+
30
+ /**
31
+ * Asynchronously run a function that will put a specified load to the funnel.
32
+ * The total amount of load of all running functions shall not
33
+ * exceed the capacity of the funnel. If the desired load exceeds the capacity
34
+ * the funnel will wait until sufficient capacity is available.
35
+ * If a function requires a load >= capacity, then it will run
36
+ * exclusively.
37
+ * @param load - the load (integer, sign will be ignored)
38
+ * @param f
39
+ * @param args
40
+ * @return {Promise<unknown>}
41
+ */
42
+ async run(load, f, ...args) {
43
+ load = min(floor(abs(load)), Number.MAX_SAFE_INTEGER);
44
+
45
+ // wait for sufficient capacity
46
+ while (this.capacity < load && this.runningPromises.length > 0) {
47
+ try {
48
+ await Promise.race(this.runningPromises);
49
+ } catch {
50
+ // Yes, we must ignore exceptions here. The
51
+ // caller expects exceptions from f and no
52
+ // exceptions from other workloads.
53
+ // Other exceptions must be handled by the
54
+ // other callers. See (*) below.
55
+ }
56
+ }
57
+
58
+ // map function call to promise
59
+ const p =
60
+ f.constructor.name === "AsyncFunction"
61
+ ? f(...args)
62
+ : Promise.resolve().then(() => f(...args));
63
+
64
+ // create promise for book keeping
65
+ const workload = p.finally(() => {
66
+ // remove workload
67
+ this.runningPromises.splice(this.runningPromises.indexOf(workload), 1);
68
+ // and reclaim its capacity
69
+ this.capacity += load;
70
+ });
71
+
72
+ // claim the capacity and schedule workload
73
+ this.capacity -= load;
74
+ this.runningPromises.push(workload);
75
+
76
+ // make the caller wait for the workload
77
+ // this also establish the seemingly missing
78
+ // exception handling. See (*) above.
79
+ return workload;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Defines a promise that resolves when all payloads are processed by the iterator, but limits
85
+ * the number concurrent executions.
86
+ *
87
+ * @param limit number of concurrent executions
88
+ * @param payloads array where each element is an array of arguments passed to the iterator
89
+ * @param iterator (async) function to process a payload
90
+ * @returns {Promise<[]>} promise for an array of iterator results
91
+ */
92
+ const limiter = async (limit, payloads, iterator) => {
93
+ const returnPromises = [];
94
+ const runningPromises = [];
95
+ for (const payload of payloads) {
96
+ const p =
97
+ iterator.constructor.name === "AsyncFunction"
98
+ ? iterator(payload)
99
+ : Promise.resolve().then(() => iterator(payload));
100
+ returnPromises.push(p);
101
+
102
+ if (limit <= payloads.length) {
103
+ const e = p
104
+ .catch(() => {})
105
+ .finally(() => runningPromises.splice(runningPromises.indexOf(e), 1));
106
+ runningPromises.push(e);
107
+ if (limit <= runningPromises.length) {
108
+ await Promise.race(runningPromises);
109
+ }
110
+ }
111
+ }
112
+ return Promise.allSettled(returnPromises);
113
+ };
114
+
115
+ module.exports = { arrayToFlatMap, Funnel, limiter };
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+
3
+ const redis = require("./redis");
4
+ const config = require("../config");
5
+ const cdsHelper = require("./cdsHelper");
6
+ const { getConfigInstance } = require("../config");
7
+
8
+ const acquireLock = async (
9
+ context,
10
+ key,
11
+ {
12
+ tenantScoped = true,
13
+ expiryTime = config.getConfigInstance().globalTxTimeout,
14
+ } = {}
15
+ ) => {
16
+ const fullKey = _generateKey(context, tenantScoped, key);
17
+ if (config.getConfigInstance().redisEnabled) {
18
+ return await _acquireLockRedis(context, fullKey, expiryTime);
19
+ } else {
20
+ return await _acquireLockDB(context, fullKey, expiryTime);
21
+ }
22
+ };
23
+
24
+ const setValueWithExpire = async (
25
+ context,
26
+ key,
27
+ value,
28
+ {
29
+ tenantScoped = true,
30
+ expiryTime = config.getConfigInstance().globalTxTimeout,
31
+ overrideValue = false,
32
+ } = {}
33
+ ) => {
34
+ const fullKey = _generateKey(context, tenantScoped, key);
35
+ if (config.getConfigInstance().redisEnabled) {
36
+ return await _acquireLockRedis(context, fullKey, expiryTime, {
37
+ value,
38
+ overrideValue,
39
+ });
40
+ } else {
41
+ return await _acquireLockDB(context, fullKey, expiryTime, {
42
+ value,
43
+ overrideValue,
44
+ });
45
+ }
46
+ };
47
+
48
+ const releaseLock = async (context, key, { tenantScoped = true } = {}) => {
49
+ const fullKey = _generateKey(context, tenantScoped, key);
50
+ if (config.getConfigInstance().redisEnabled) {
51
+ return await _releaseLockRedis(context, fullKey);
52
+ } else {
53
+ return await _releaseLockDb(context, fullKey);
54
+ }
55
+ };
56
+
57
+ const checkLockExistsAndReturnValue = async (
58
+ context,
59
+ key,
60
+ { tenantScoped = true } = {}
61
+ ) => {
62
+ const fullKey = _generateKey(context, tenantScoped, key);
63
+ if (config.getConfigInstance().redisEnabled) {
64
+ return await _checkLockExistsRedis(context, fullKey);
65
+ } else {
66
+ return await _checkLockExistsDb(context, fullKey);
67
+ }
68
+ };
69
+
70
+ const _acquireLockRedis = async (
71
+ context,
72
+ fullKey,
73
+ expiryTime,
74
+ { value = "true", overrideValue = false } = {}
75
+ ) => {
76
+ const client = await redis.createMainClientAndConnect();
77
+ const result = await client.set(fullKey, value, {
78
+ PX: expiryTime,
79
+ ...(overrideValue ? null : { NX: true }),
80
+ });
81
+ return result === "OK";
82
+ };
83
+
84
+ const _checkLockExistsRedis = async (context, fullKey) => {
85
+ const client = await redis.createMainClientAndConnect();
86
+ return await client.get(fullKey);
87
+ };
88
+
89
+ const _checkLockExistsDb = async (context, fullKey) => {
90
+ let result;
91
+ const configInstance = getConfigInstance();
92
+ await cdsHelper.executeInNewTransaction(
93
+ context,
94
+ "distributedLock-checkExists",
95
+ async (tx) => {
96
+ result = await tx.run(
97
+ SELECT.one
98
+ .from(configInstance.tableNameEventLock)
99
+ .where("code =", fullKey)
100
+ );
101
+ }
102
+ );
103
+ return result?.value;
104
+ };
105
+
106
+ const _releaseLockRedis = async (context, fullKey) => {
107
+ const client = await redis.createMainClientAndConnect();
108
+ await client.del(fullKey);
109
+ };
110
+
111
+ const _releaseLockDb = async (context, fullKey) => {
112
+ const configInstance = getConfigInstance();
113
+ await cdsHelper.executeInNewTransaction(
114
+ context,
115
+ "distributedLock-release",
116
+ async (tx) => {
117
+ await tx.run(
118
+ DELETE.from(configInstance.tableNameEventLock).where("code =", fullKey)
119
+ );
120
+ }
121
+ );
122
+ };
123
+
124
+ const _acquireLockDB = async (
125
+ context,
126
+ fullKey,
127
+ expiryTime,
128
+ { value = "true", overrideValue = false } = {}
129
+ ) => {
130
+ let result;
131
+ const configInstance = getConfigInstance();
132
+ await cdsHelper.executeInNewTransaction(
133
+ context,
134
+ "distributedLock-acquire",
135
+ async (tx) => {
136
+ try {
137
+ await tx.run(
138
+ INSERT.into(configInstance.tableNameEventLock).entries({
139
+ code: fullKey,
140
+ value,
141
+ })
142
+ );
143
+ result = true;
144
+ } catch (err) {
145
+ let currentEntry;
146
+
147
+ if (!overrideValue) {
148
+ currentEntry = await tx.run(
149
+ SELECT.one
150
+ .from(configInstance.tableNameEventLock)
151
+ .forUpdate({ wait: config.getConfigInstance().forUpdateTimeout })
152
+ .where("code =", fullKey)
153
+ );
154
+ }
155
+ if (
156
+ overrideValue ||
157
+ (currentEntry &&
158
+ new Date(currentEntry.createdAt).getTime() + expiryTime <=
159
+ Date.now())
160
+ ) {
161
+ await tx.run(
162
+ UPDATE.entity(configInstance.tableNameEventLock)
163
+ .set({
164
+ createdAt: new Date().toISOString(),
165
+ value,
166
+ })
167
+ .where("code =", currentEntry.code)
168
+ );
169
+ result = true;
170
+ } else {
171
+ result = false;
172
+ }
173
+ }
174
+ }
175
+ );
176
+ return result;
177
+ };
178
+
179
+ const _generateKey = (context, tenantScoped, key) => {
180
+ const keyParts = [];
181
+ tenantScoped && keyParts.push(context.tenant);
182
+ keyParts.push(key);
183
+ return keyParts.join("##");
184
+ };
185
+
186
+ module.exports = {
187
+ acquireLock,
188
+ releaseLock,
189
+ checkLockExistsAndReturnValue,
190
+ setValueWithExpire,
191
+ };
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+
3
+ const isLocal = process.env.USER !== "vcap";
4
+ const isOnCF = !isLocal;
5
+
6
+ module.exports = {
7
+ isOnCF,
8
+ isLocal,
9
+ };
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+
3
+ const redis = require("redis");
4
+
5
+ const config = require("../config");
6
+ const EventQueueError = require("../EventQueueError");
7
+
8
+ const COMPONENT_NAME = "eventQueue/shared/redis";
9
+
10
+ let subscriberClientPromise;
11
+
12
+ const createMainClientAndConnect = () => {
13
+ if (subscriberClientPromise) {
14
+ return subscriberClientPromise;
15
+ }
16
+
17
+ const errorHandlerCreateClient = (err) => {
18
+ cds
19
+ .log(COMPONENT_NAME)
20
+ .error("error from redis client for pub/sub failed", err);
21
+ subscriberClientPromise = null;
22
+ setTimeout(createMainClientAndConnect, 5 * 1000).unref();
23
+ };
24
+ subscriberClientPromise = createClientAndConnect(errorHandlerCreateClient);
25
+ return subscriberClientPromise;
26
+ };
27
+
28
+ const _createClientBase = () => {
29
+ const configInstance = config.getConfigInstance();
30
+ if (configInstance.isOnCF) {
31
+ try {
32
+ const credentials = configInstance.getRedisCredentialsFromEnv();
33
+ // NOTE: settings the user explicitly to empty resolves auth problems, see
34
+ // https://github.com/go-redis/redis/issues/1343
35
+ const url = credentials.uri.replace(/(?<=rediss:\/\/)[\w-]+?(?=:)/, "");
36
+ return redis.createClient({ url });
37
+ } catch (err) {
38
+ throw EventQueueError.redisConnectionFailure(err);
39
+ }
40
+ } else {
41
+ return redis.createClient({
42
+ socket: { reconnectStrategy: _localReconnectStrategy },
43
+ });
44
+ }
45
+ };
46
+
47
+ const createClientAndConnect = async (errorHandlerCreateClient) => {
48
+ let client = null;
49
+ try {
50
+ client = _createClientBase();
51
+ } catch (err) {
52
+ throw EventQueueError.redisConnectionFailure(err);
53
+ }
54
+
55
+ client.on("error", errorHandlerCreateClient);
56
+
57
+ try {
58
+ await client.connect();
59
+ } catch (err) {
60
+ errorHandlerCreateClient(err);
61
+ }
62
+ return client;
63
+ };
64
+
65
+ const _localReconnectStrategy = () => EventQueueError.redisNoReconnect();
66
+
67
+ module.exports = {
68
+ createClientAndConnect,
69
+ createMainClientAndConnect,
70
+ };