cds-caching 0.3.3 → 1.1.0

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,373 @@
1
+ const cds = require('@sap/cds');
2
+ const CacheStoreManager = require('./support/CacheStoreManager');
3
+ const KeyManager = require('./support/KeyManager');
4
+ const TagResolver = require('./support/TagResolver');
5
+ const RuntimeConfigurationManager = require('./support/RuntimeConfigurationManager');
6
+ const CacheStatisticsHandler = require('./support/CacheStatisticsHandler');
7
+ const BasicOperations = require('./operations/BasicOperations');
8
+ const CapOperations = require('./operations/CapOperations');
9
+ const AsyncOperations = require('./operations/AsyncOperations');
10
+
11
+ class CachingService extends cds.Service {
12
+
13
+ async init() {
14
+ super.init()
15
+ this.log = cds.log('cds-caching')
16
+ this.options = this.options || {
17
+ store: null,
18
+ compression: null,
19
+ credentials: {},
20
+ namespace: null,
21
+ throwOnErrors: false,
22
+ };
23
+
24
+ // Initialize managers
25
+ this.storeManager = new CacheStoreManager();
26
+ this.runtimeConfigManager = new RuntimeConfigurationManager(this.name, this.log, this.options);
27
+ this.keyManager = new KeyManager(this.runtimeConfigManager);
28
+ this.tagResolver = new TagResolver();
29
+
30
+ // Create cache store
31
+ const { cache, cleanup } = this.storeManager.createStore(this.options, this.name);
32
+ this.cache = cache;
33
+ this.log.info(`Caching service ${this.name} initialized with namespace ${this.options.namespace || this.name}`);
34
+
35
+ // Set up cleanup on shutdown
36
+ cds.once("shutdown", cleanup);
37
+
38
+ /**
39
+ * Internal event handlers
40
+ */
41
+ const handleSet = async (event) => {
42
+ this.log.debug(`SET ${event.data.key}`);
43
+ if (typeof event.data.value === "object") {
44
+ event.data.value = JSON.stringify(event.data.value);
45
+ }
46
+ await this.cache.set(event.data.key, event.data.value, (event.data.ttl || 0))
47
+ }
48
+
49
+ const handleGet = async (event) => {
50
+ const value = await this.cache.get(event.data.key);
51
+ this.log.debug(`GET ${event.data.key}`);
52
+ if (typeof value === "string") {
53
+ try {
54
+ return JSON.parse(value);
55
+ } catch (error) {
56
+ return value;
57
+ }
58
+ }
59
+ return value;
60
+ }
61
+
62
+ const handleDelete = async (event) => {
63
+ this.log.debug(`DELETE ${event.data.key}`);
64
+ await this.cache.delete(event.data.key);
65
+ }
66
+
67
+ const handleClear = async (event) => {
68
+ this.log.debug(`CLEAR`);
69
+ await this.cache.clear();
70
+
71
+ // Also clear statistics
72
+ if (this.statistics) {
73
+ await this.statistics.resetCurrentStats();
74
+
75
+ try {
76
+ await this.statistics.deleteMetrics();
77
+ await this.statistics.deleteKeyMetrics();
78
+ } catch (error) {
79
+ this.log.warn(`Failed to clear statistics for cache ${this.name}:`, error);
80
+ }
81
+ }
82
+ }
83
+
84
+ // Those support before/after hooks
85
+ this.on('SET', handleSet.bind(this));
86
+ this.on('GET', handleGet.bind(this));
87
+ this.on('DELETE', handleDelete.bind(this));
88
+ this.on('CLEAR', handleClear.bind(this));
89
+
90
+ const config = await this.runtimeConfigManager.getRuntimeConfiguration();
91
+
92
+ // Initialize statistics with runtime configuration support
93
+ this.statistics = new CacheStatisticsHandler({
94
+ cache: this.name
95
+ });
96
+ this.statistics.enableKeyMetrics(config.keyMetricsEnabled);
97
+ this.statistics.enableMetrics(config.metricsEnabled);
98
+
99
+ // Initialize operation managers
100
+ this.basicOperations = new BasicOperations(
101
+ this,
102
+ this.keyManager,
103
+ this.tagResolver,
104
+ this.statistics,
105
+ );
106
+
107
+ this.capOperations = new CapOperations(
108
+ this,
109
+ this.keyManager,
110
+ this.statistics,
111
+ this.log,
112
+ this.runtimeConfigManager
113
+ );
114
+
115
+ this.asyncOperations = new AsyncOperations(
116
+ this,
117
+ this.keyManager,
118
+ this.statistics,
119
+ this.runtimeConfigManager
120
+ );
121
+
122
+
123
+ // Always set up statistics hooks, but they will be controlled by the enabled flag
124
+ this.setupStatisticsHooks();
125
+ }
126
+
127
+ // ============================================================================
128
+ // PUBLIC API - Core Cache Operations
129
+ // ============================================================================
130
+
131
+ createKey(...args) { return this.keyManager.createKey(...args); }
132
+
133
+ async set(...args) { return this.basicOperations.set(...args); }
134
+ async get(...args) { return this.basicOperations.get(...args); }
135
+ async has(...args) { return this.basicOperations.has(...args); }
136
+ async delete(...args) { return this.basicOperations.delete(...args); }
137
+ async clear(...args) { return this.basicOperations.clear(...args); }
138
+ async deleteByTag(...args) { return this.basicOperations.deleteByTag(...args); }
139
+ async metadata(...args) { return this.basicOperations.metadata(...args); }
140
+ async tags(...args) { return this.basicOperations.tags(...args); }
141
+ async *iterator(...args) { return yield* this.basicOperations.iterator(...args); }
142
+
143
+ // ============================================================================
144
+ // PUBLIC API - Read Through Operations
145
+ // ============================================================================
146
+
147
+ get rt() {
148
+ return {
149
+ send: (...args) => this.capOperations.send(...args),
150
+ run: (...args) => this.capOperations.run(...args),
151
+ wrap: (...args) => this.asyncOperations.wrap(...args),
152
+ exec: (...args) => this.asyncOperations.exec(...args),
153
+ }
154
+ }
155
+
156
+ // ============================================================================
157
+ // PUBLIC API - Deprecated Shortcut Read Through Methods
158
+ // ============================================================================
159
+
160
+ /**
161
+ * @deprecated Use cache.rt.send() instead. The rt.send() method provides enhanced functionality including read-through metadata, dynamic cache keys, and detailed mode options.
162
+ * @param {...any} args
163
+ * @returns {Promise<object>} - the result of the request
164
+ */
165
+ async send(...args) {
166
+ if (typeof args[0] !== "object" || !args[1].send || typeof args[1] !== "object" || args[0].method !== "GET") {
167
+ return super.send(...args);
168
+ } else {
169
+ const result = await this.rt.send(...args);
170
+ return result.result;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * @deprecated Use cache.rt.run() instead. The rt.run() method provides enhanced functionality including read-through metadata, dynamic cache keys, and detailed mode options.
176
+ * @param {...any} args
177
+ * @returns {Promise<object>} - the result of the request
178
+ */
179
+ async run(...args) {
180
+ if (typeof args[0] !== "object" && !["Request", "NoaRequest", "ODataRequest", "cds.ql"].includes(args[0].constructor.name)) {
181
+ return super.run(...args);
182
+ } else {
183
+ const result = await this.rt.run(...args);
184
+ return result.result;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * @deprecated Use cache.rt.wrap() instead. The rt.wrap() method provides enhanced functionality including read-through metadata, dynamic cache keys, and detailed mode options.
190
+ * @param {...any} args
191
+ * @returns {Promise<object>} - the result of the request
192
+ */
193
+ wrap(...args) {
194
+ const wrappedFunction = this.rt.wrap(...args);
195
+ return async (...args) => {
196
+ const result = await wrappedFunction(...args);
197
+ return result.result;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * @deprecated Use cache.rt.exec() instead. The rt.exec() method provides enhanced functionality including read-through metadata, dynamic cache keys, and detailed mode options.
203
+ * @param {...any} args
204
+ * @returns {Promise<object>} - the result of the request
205
+ */
206
+ async exec(...args) {
207
+ const result = await this.rt.exec(...args);
208
+ return result.result;
209
+ }
210
+
211
+ // ============================================================================
212
+ // PUBLIC API - Statistics and Configuration
213
+ // ============================================================================
214
+
215
+
216
+ /**
217
+ * Resolve tags for a given query
218
+ * @param {object} query - the query to resolve tags for
219
+ * @returns {Promise<object>} - the resolved tags
220
+ */
221
+ async resolveTags(...args) { return this.tagResolver.resolveTags(...args); }
222
+
223
+ /**
224
+ * Get statistics for a specific period
225
+ * @param {string} period - the period to get stats for
226
+ * @param {Date} from - start date
227
+ * @param {Date} to - end date
228
+ * @returns {Promise<object>} - the statistics
229
+ */
230
+ async getMetrics(from, to) {
231
+ return this.statistics.getMetrics("hourly", from, to);
232
+ }
233
+
234
+ async getKeyMetrics(key, from, to) {
235
+ return this.statistics.getKeyMetrics(key, from, to);
236
+ }
237
+
238
+ /**
239
+ * Get current statistics
240
+ * @returns {Promise<object>} - the current statistics
241
+ */
242
+ async getCurrentMetrics() {
243
+ return this.statistics.getCurrentStats();
244
+ }
245
+
246
+ /**
247
+ * Get current key metrics
248
+ * @returns {Promise<object>} - the current key metrics
249
+ */
250
+ async getCurrentKeyMetrics() {
251
+ return this.statistics.getCurrentKeyMetrics();
252
+ }
253
+
254
+
255
+
256
+ /**
257
+ * Clear all metrics
258
+ * @returns {Promise<void>}
259
+ */
260
+ async clearMetrics() {
261
+ return this.statistics.clearMetrics();
262
+ }
263
+
264
+ /**
265
+ * Clear key metrics
266
+ * @returns {Promise<void>}
267
+ */
268
+ async clearKeyMetrics() {
269
+ return this.statistics.clearKeyMetrics();
270
+ }
271
+
272
+ /**
273
+ * Enable or disable statistics at runtime
274
+ * @param {boolean} enabled - whether to enable statistics
275
+ * @returns {Promise<void>}
276
+ */
277
+ async setMetricsEnabled(enabled) {
278
+ await this.runtimeConfigManager.setMetricsEnabled(enabled);
279
+ this.statistics.enableMetrics(enabled);
280
+ }
281
+
282
+ /**
283
+ * Enable or disable key tracking at runtime
284
+ * @param {boolean} enabled - whether to enable key tracking
285
+ * @returns {Promise<void>}
286
+ */
287
+ async setKeyMetricsEnabled(enabled) {
288
+ await this.runtimeConfigManager.setKeyMetricsEnabled(enabled);
289
+ this.statistics.enableKeyMetrics(enabled);
290
+ }
291
+
292
+ /**
293
+ * Persist metrics to database
294
+ * @returns {Promise<void>}
295
+ */
296
+ async persistMetrics() {
297
+ return this.statistics.persistMetrics();
298
+ }
299
+
300
+ /**
301
+ * Get current runtime configuration
302
+ * @returns {Promise<object>} - the runtime configuration
303
+ */
304
+ async getRuntimeConfiguration() {
305
+ return await this.runtimeConfigManager.getRuntimeConfiguration();
306
+ }
307
+
308
+ /**
309
+ * Reload runtime configuration from database
310
+ * @returns {Promise<void>}
311
+ */
312
+ async reloadRuntimeConfiguration() {
313
+ await this.loadRuntimeConfiguration();
314
+ }
315
+
316
+ /**
317
+ * Add a cachable function
318
+ * @param {string} name - the function name
319
+ * @param {object} options - the cache options
320
+ * @param {boolean} isBound - whether the function is bound
321
+ */
322
+ addCachableFunction(name, options, isBound = false) {
323
+ this.capOperations.cacheAnnotatedFunctions[isBound ? 'bound' : 'unbound'].push({ name, options });
324
+ }
325
+
326
+ /**
327
+ * Dispose of the service
328
+ * @returns {Promise<void>}
329
+ */
330
+ async dispose() {
331
+ await this.statistics.dispose();
332
+ }
333
+
334
+ // ============================================================================
335
+ // PRIVATE METHODS
336
+ // ============================================================================
337
+
338
+ /**
339
+ * Set up statistics hooks for all cache operations
340
+ * @private
341
+ */
342
+ setupStatisticsHooks() {
343
+ // Enhance methods with statistics
344
+ this.before('GET', (req) => {
345
+ req.startTime = process.hrtime();
346
+ });
347
+
348
+ this.before('SET', (req) => {
349
+ req.startTime = process.hrtime();
350
+ });
351
+
352
+ this.before('DELETE', (req) => {
353
+ req.startTime = process.hrtime();
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Load runtime configuration from database
359
+ * @private
360
+ */
361
+ async loadRuntimeConfiguration() {
362
+ try {
363
+ const config = await this.runtimeConfigManager.loadRuntimeConfiguration();
364
+ this.statistics.enableKeyTracking(config.enableKeyTracking);
365
+ this.statistics.enableStatistics(config.enableStatistics);
366
+ } catch (error) {
367
+ this.log.warn(`Failed to load runtime configuration for cache ${this.name}:`, error);
368
+ }
369
+ }
370
+
371
+ }
372
+
373
+ module.exports = CachingService;
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Manages async cache operations for function wrapping and execution
3
+ */
4
+ class AsyncOperations {
5
+ constructor(cache, keyManager, statistics, runtimeConfigManager) {
6
+ this.cache = cache;
7
+ this.keyManager = keyManager;
8
+ this.statistics = statistics;
9
+ this.runtimeConfigManager = runtimeConfigManager;
10
+ this.log = console; // Default logger
11
+ }
12
+
13
+ /**
14
+ * Safely execute cache operations with error handling
15
+ * @param {Function} operation - The cache operation to execute
16
+ * @param {string} operationName - Name of the operation for logging
17
+ * @param {object} context - Context information for logging
18
+ * @returns {Promise<object>} - The result with error information
19
+ */
20
+ async safeCacheOperation(operation, operationName, context = {}) {
21
+ try {
22
+ const result = await operation();
23
+ return { success: true, result, error: null };
24
+ } catch (error) {
25
+ this.log.warn(`Cache ${operationName} failed:`, {
26
+ error: error.message,
27
+ stack: error.stack,
28
+ context: context
29
+ });
30
+ return {
31
+ success: false,
32
+ result: null,
33
+ error: {
34
+ message: error.message,
35
+ operation: operationName,
36
+ context: context
37
+ }
38
+ };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Create a dynamic key for async function caching
44
+ * @param {string} baseKey - the base cache key
45
+ * @param {Array} args - function arguments
46
+ * @param {object} options - cache options
47
+ * @param {string} functionName - name of the function
48
+ * @returns {string} - generated cache key
49
+ */
50
+ createDynamicKey(baseKey, args, options, functionName) {
51
+ // If explicit key is provided, use it
52
+ if (options.key) {
53
+ return this.keyManager.createKey(
54
+ { baseKey, args, functionName },
55
+ {
56
+ baseKey,
57
+ args,
58
+ functionName,
59
+ user: cds.context?.user?.id,
60
+ tenant: cds.context?.tenant,
61
+ locale: cds.context?.locale
62
+ },
63
+ options.key
64
+ );
65
+ }
66
+
67
+ // Auto-generate template based on arguments
68
+ if (args.length === 0) {
69
+ return this.keyManager.createKey(baseKey);
70
+ }
71
+
72
+ // Create argument placeholders
73
+ const argPlaceholders = args.map((_, index) => `{args[${index}]}`).join(':');
74
+ const template = `${baseKey}:${argPlaceholders}`;
75
+
76
+ return this.keyManager.createKey(
77
+ { baseKey, args, functionName },
78
+ {
79
+ baseKey,
80
+ args,
81
+ functionName,
82
+ user: cds.context?.user?.id,
83
+ tenant: cds.context?.tenant,
84
+ locale: cds.context?.locale
85
+ },
86
+ template
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Wrap an async function with caching
92
+ * @param {string} key - the cache key
93
+ * @param {function} asyncFunction - the async function to wrap
94
+ * @param {object} options - cache options
95
+ * @returns {function} - the wrapped function
96
+ */
97
+ wrap(key, asyncFunction, options = {}) {
98
+ return async (...args) => {
99
+ // Create dynamic key based on function arguments
100
+ const cacheKey = this.createDynamicKey(key, args, options, asyncFunction.name || 'anonymous');
101
+
102
+ const startTime = process.hrtime();
103
+ const metadata = {
104
+ dataType: 'Function',
105
+ operation: 'WRAP',
106
+ operationType: 'READ_THROUGH',
107
+ user: cds.context?.user?.id,
108
+ tenant: cds.context?.tenant,
109
+ locale: cds.context?.locale,
110
+ metadata: JSON.stringify({
111
+ functionName: asyncFunction.name || 'anonymous',
112
+ args: args,
113
+ cacheKey: cacheKey
114
+ }),
115
+ cacheOptions: JSON.stringify(options)
116
+ };
117
+
118
+ // Safely check if key exists in cache
119
+ const hasKeyResult = await this.safeCacheOperation(
120
+ () => this.cache.has(cacheKey),
121
+ 'has',
122
+ { key: cacheKey, functionName: asyncFunction.name || 'anonymous' }
123
+ );
124
+
125
+ const hasKey = hasKeyResult.success && hasKeyResult.result;
126
+ const cacheErrors = [];
127
+
128
+ if (hasKey) {
129
+ const latency = this.getElapsedMs(startTime);
130
+
131
+ // Safely record hit statistics
132
+ const hitStatsResult = await this.safeCacheOperation(
133
+ () => this.statistics.recordHit(latency, cacheKey, metadata),
134
+ 'recordHit',
135
+ { key: cacheKey, latency }
136
+ );
137
+ if (!hitStatsResult.success) {
138
+ cacheErrors.push(hitStatsResult.error);
139
+ }
140
+
141
+ // Safely get value from cache
142
+ const getResult = await this.safeCacheOperation(
143
+ () => this.cache.send("GET", { key: cacheKey }),
144
+ 'get',
145
+ { key: cacheKey }
146
+ );
147
+
148
+ if (getResult.success && getResult.result?.value !== undefined) {
149
+ return {
150
+ result: getResult.result.value,
151
+ cacheKey,
152
+ metadata: { hit: true, latency },
153
+ cacheErrors: cacheErrors
154
+ };
155
+ }
156
+ }
157
+
158
+ // Cache miss or cache error - delegate to underlying function
159
+ try {
160
+ const response = await asyncFunction(...args);
161
+ const latency = this.getElapsedMs(startTime);
162
+
163
+ // Safely record miss statistics
164
+ const missStatsResult = await this.safeCacheOperation(
165
+ () => this.statistics.recordMiss(latency, cacheKey, metadata),
166
+ 'recordMiss',
167
+ { key: cacheKey, latency }
168
+ );
169
+ if (!missStatsResult.success) {
170
+ cacheErrors.push(missStatsResult.error);
171
+ }
172
+
173
+ // Safely store in cache
174
+ const wrappedValue = {
175
+ value: response,
176
+ tags: options.tags || [],
177
+ timestamp: Date.now()
178
+ };
179
+
180
+ const setResult = await this.safeCacheOperation(
181
+ () => this.cache.send("SET", { key: cacheKey, value: wrappedValue, ttl: options.ttl || 0 }),
182
+ 'set',
183
+ { key: cacheKey, ttl: options.ttl }
184
+ );
185
+ if (!setResult.success) {
186
+ cacheErrors.push(setResult.error);
187
+ }
188
+
189
+ return {
190
+ result: response,
191
+ cacheKey,
192
+ metadata: { hit: false, latency },
193
+ cacheErrors: cacheErrors
194
+ };
195
+ } catch (functionError) {
196
+ // If the underlying function fails, throw the error
197
+ this.log.error('Function execution failed:', {
198
+ error: functionError.message,
199
+ functionName: asyncFunction.name || 'anonymous',
200
+ key: cacheKey
201
+ });
202
+ throw functionError;
203
+ }
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Executes an async function and caches its result
209
+ * @param {string} key - the key to cache
210
+ * @param {function} asyncFunction - the async function to execute
211
+ * @param {Array} args - function arguments
212
+ * @param {object} options - additional options
213
+ * @returns {Promise<any>} - the result
214
+ */
215
+ async exec(key, asyncFunction, args = [], options = {}) {
216
+ // Create dynamic key based on function arguments
217
+ const cacheKey = this.createDynamicKey(key, args, options, asyncFunction.name || 'anonymous');
218
+
219
+ const startTime = process.hrtime();
220
+
221
+ const metadata = {
222
+ dataType: 'Function',
223
+ operation: 'EXEC',
224
+ operationType: 'READ_THROUGH',
225
+ user: cds.context?.user?.id,
226
+ tenant: cds.context?.tenant,
227
+ locale: cds.context?.locale,
228
+ metadata: JSON.stringify({
229
+ functionName: asyncFunction.name || 'anonymous',
230
+ args: args,
231
+ cacheKey: cacheKey
232
+ }),
233
+ cacheOptions: JSON.stringify(options)
234
+ };
235
+
236
+ // Safely check if key exists in cache
237
+ const hasKeyResult = await this.safeCacheOperation(
238
+ () => this.cache.has(cacheKey),
239
+ 'has',
240
+ { key: cacheKey, functionName: asyncFunction.name || 'anonymous' }
241
+ );
242
+
243
+ const hasKey = hasKeyResult.success && hasKeyResult.result;
244
+ const cacheErrors = [];
245
+
246
+ if (hasKey) {
247
+ const latency = this.getElapsedMs(startTime);
248
+
249
+ // Safely record hit statistics
250
+ const hitStatsResult = await this.safeCacheOperation(
251
+ () => this.statistics.recordHit(latency, cacheKey, metadata),
252
+ 'recordHit',
253
+ { key: cacheKey, latency }
254
+ );
255
+ if (!hitStatsResult.success) {
256
+ cacheErrors.push(hitStatsResult.error);
257
+ }
258
+
259
+ // Safely get value from cache
260
+ const getResult = await this.safeCacheOperation(
261
+ () => this.cache.send("GET", { key: cacheKey }),
262
+ 'get',
263
+ { key: cacheKey }
264
+ );
265
+
266
+ if (getResult.success && getResult.result?.value !== undefined) {
267
+ return {
268
+ result: getResult.result.value,
269
+ cacheKey,
270
+ metadata: { hit: true, latency },
271
+ cacheErrors: cacheErrors
272
+ };
273
+ }
274
+ }
275
+
276
+ // Cache miss or cache error - delegate to underlying function
277
+ try {
278
+ const response = await asyncFunction(...args);
279
+ const latency = this.getElapsedMs(startTime);
280
+
281
+ // Safely record miss statistics
282
+ const missStatsResult = await this.safeCacheOperation(
283
+ () => this.statistics.recordMiss(latency, cacheKey, metadata),
284
+ 'recordMiss',
285
+ { key: cacheKey, latency }
286
+ );
287
+ if (!missStatsResult.success) {
288
+ cacheErrors.push(missStatsResult.error);
289
+ }
290
+
291
+ // Safely store in cache
292
+ const wrappedValue = {
293
+ value: response,
294
+ tags: options.tags || [],
295
+ timestamp: Date.now()
296
+ };
297
+
298
+ const setResult = await this.safeCacheOperation(
299
+ () => this.cache.send("SET", { key: cacheKey, value: wrappedValue, ttl: options.ttl || 0 }),
300
+ 'set',
301
+ { key: cacheKey, ttl: options.ttl }
302
+ );
303
+ if (!setResult.success) {
304
+ cacheErrors.push(setResult.error);
305
+ }
306
+
307
+ return {
308
+ result: response,
309
+ cacheKey,
310
+ metadata: { hit: false, latency },
311
+ cacheErrors: cacheErrors
312
+ };
313
+ } catch (functionError) {
314
+ // If the underlying function fails, throw the error
315
+ this.log.error('Function execution failed:', {
316
+ error: functionError.message,
317
+ functionName: asyncFunction.name || 'anonymous',
318
+ key: cacheKey
319
+ });
320
+ throw functionError;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Get elapsed time in milliseconds
326
+ * @param {[number, number]} startTime - start time from process.hrtime()
327
+ * @returns {number} - elapsed time in milliseconds
328
+ */
329
+ getElapsedMs(startTime) {
330
+ const [seconds, nanoseconds] = process.hrtime(startTime);
331
+ return (seconds * 1000) + (nanoseconds / 1000000);
332
+ }
333
+ }
334
+
335
+ module.exports = AsyncOperations;