@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.
@@ -2,66 +2,107 @@
2
2
 
3
3
  const cds = require("@sap/cds");
4
4
 
5
- const { getConfigInstance } = require("../config");
5
+ const config = require("../config");
6
+ const EventQueueError = require("../EventQueueError");
6
7
 
7
8
  const COMPONENT_NAME = "eventQueue/WorkerQueue";
8
-
9
- let instance = null;
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.__concurrencyLimit = 1;
25
+ this.#concurrencyLimit = 1;
15
26
  } else {
16
- this.__concurrencyLimit = concurrency;
27
+ this.#concurrencyLimit = concurrency;
17
28
  }
18
- this.__runningPromises = [];
19
- this.__queue = [];
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.__queue.push([cb, resolve, reject]);
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.__runningPromises.push(promise);
50
+ this.#runningPromises.push(promise);
51
+ this.#runningLoad = this.#runningLoad + load;
33
52
  promise
34
53
  .finally(() => {
35
- this.__runningPromises.splice(this.__runningPromises.indexOf(promise), 1);
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! Error:", err);
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
- if (!this.__queue.length || this.__runningPromises.length >= this.__concurrencyLimit) {
68
+ const load = this.#queue[0]?.[0];
69
+ if (!this.#queue.length || this.#runningLoad + load > this.#concurrencyLimit) {
49
70
  return;
50
71
  }
51
- const [cb, resolve, reject] = this.__queue.shift();
52
- this._executeFunction(cb, resolve, reject);
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
- module.exports = {
57
- getWorkerPoolInstance: () => {
58
- if (!instance) {
59
- const configInstance = getConfigInstance();
60
- instance = new WorkerQueue(configInstance.parallelTenantProcessing);
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
- WorkerQueue,
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;
@@ -94,7 +94,7 @@ class TriggerRollback extends VError {
94
94
  }
95
95
 
96
96
  const getSubdomainForTenantId = async (tenantId) => {
97
- if (!config.getConfigInstance().isMultiTenancy) {
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.getConfigInstance().isMultiTenancy) {
114
+ if (!config.isMultiTenancy) {
115
115
  return null;
116
116
  }
117
117
  const ssp = await cds.connect.to("cds.xt.SaasProvisioningService");
@@ -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, Funnel, limiter, isValidDate, processChunkedSync, hashStringTo32Bit };
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 acquireLock = async (
9
- context,
10
- key,
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.getConfigInstance().redisEnabled) {
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.getConfigInstance().globalTxTimeout, overrideValue = false } = {}
22
+ { tenantScoped = true, expiryTime = config.globalTxTimeout, overrideValue = false } = {}
26
23
  ) => {
27
24
  const fullKey = _generateKey(context, tenantScoped, key);
28
- if (config.getConfigInstance().redisEnabled) {
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.getConfigInstance().redisEnabled) {
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.getConfigInstance().redisEnabled) {
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(configInstance.tableNameEventLock).where("code =", fullKey));
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(configInstance.tableNameEventLock).where("code =", fullKey));
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(configInstance.tableNameEventLock).entries({
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(configInstance.tableNameEventLock)
113
- .forUpdate({ wait: config.getConfigInstance().forUpdateTimeout })
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(configInstance.tableNameEventLock)
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 configInstance = config.getConfigInstance();
17
- const eventConfig = configInstance.getEventConfig(type, subType);
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: (roundUpDate.getTime() - Date.now()) / 1000,
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: roundUpDate.toISOString(),
34
+ scheduledFor: date.toISOString(),
38
35
  });
39
36
  });
40
- }, roundUpDate.getTime() - Date.now()).unref();
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) {