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