cds-caching 1.0.0 → 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.
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
1
  # Welcome to cds-caching
2
+ [![npm version](https://img.shields.io/npm/v/cds-caching)](https://www.npmjs.com/package/cds-caching/common)
3
+ [![monthly downloads](https://img.shields.io/npm/dm/cds-caching)](https://www.npmjs.com/package/cds-caching)
2
4
 
3
5
  ## Overview
4
6
 
@@ -136,45 +138,27 @@ await cache.set(data, value, {
136
138
 
137
139
  For detailed API documentation, see [Programmatic API Reference](docs/programmatic-api.md).
138
140
 
139
- ### Installation
140
-
141
- Installing and using cds-caching is straightforward since it's a CAP plugin. Simply run:
142
-
143
- ```bash
144
- npm install cds-caching
145
- ```
146
-
147
- ### TypeScript Support
141
+ ### Example Application
148
142
 
149
- cds-caching includes comprehensive TypeScript definitions. The library is written in JavaScript but provides full TypeScript support for better development experience.
150
-
151
- #### Basic Usage with TypeScript
143
+ The cds-caching plugin includes a comprehensive example application demonstrating various caching use cases and a UI5-based dashboard for monitoring cache performance.
152
144
 
153
- ```typescript
154
- import { CachingService, CacheOptions, ReadThroughResult } from 'cds-caching';
145
+ ![Cache Dashboard](./docs/dashboard.jpg)
155
146
 
156
- const cache = await cds.connect.to('caching') as CachingService;
147
+ The example consists of:
148
+ - **Backend Application** (`examples/app/`) - A CAP application showing annotation-based and programmatic caching patterns
149
+ - **Dashboard** (`examples/dashboard/`) - A UI5-based monitoring interface with real-time metrics, key-level analytics, and historical data
157
150
 
158
- // Basic cache operations
159
- await cache.set('my-key', { data: 'value' }, { ttl: 3600 });
160
- const value = await cache.get('my-key');
151
+ [See the full Example Application Guide →](docs/example-app.md)
161
152
 
162
- // Read-through operations with full type safety
163
- const { result, cacheKey, metadata } = await cache.rt.send(request, service, {
164
- ttl: 1800,
165
- tags: ['user-data']
166
- });
167
153
 
168
- // Function wrapping with type inference
169
- const cachedFunction = cache.rt.wrap('expensive-operation', async (id: string) => {
170
- return await this.performExpensiveOperation(id);
171
- });
154
+ ### Installation
172
155
 
173
- const { result: operationResult } = await cachedFunction('user-123');
156
+ Installing and using cds-caching is straightforward since it's a CAP plugin. Simply run:
174
157
 
158
+ ```bash
159
+ npm install cds-caching
175
160
  ```
176
161
 
177
-
178
162
  ### Configuration
179
163
 
180
164
  The cds-caching plugin supports comprehensive configuration through `package.json`. Here are all available configuration options:
@@ -212,6 +196,7 @@ The cds-caching plugin supports comprehensive configuration through `package.jso
212
196
  "namespace": "caching",
213
197
  "store": "in-memory", // "in-memory", "sqlite", or "redis"
214
198
  "compression": "lz4", // "lz4" or "gzip"
199
+ "throwOnErrors": false, // Whether basic operations should throw errors (default: false)
215
200
  "credentials": {
216
201
  // Redis configuration
217
202
  "host": "localhost",
@@ -253,6 +238,36 @@ Configure default key templates for read-through operations:
253
238
 
254
239
  **Default behavior** (if not configured): All context elements are disabled by default.
255
240
 
241
+ #### Error Handling Configuration
242
+
243
+ Configure how the caching service handles errors:
244
+
245
+ ```json
246
+ {
247
+ "cds": {
248
+ "requires": {
249
+ "caching": {
250
+ ...
251
+ "throwOnErrors": true, // Basic operations (set, get, delete, has) throw errors
252
+ // Default: false - operations return undefined/null instead of throwing
253
+ }
254
+ }
255
+ }
256
+ }
257
+ ```
258
+
259
+ **Error Handling Behavior:**
260
+
261
+ - **Basic Operations** (`set`, `get`, `delete`, `has`):
262
+ - When `throwOnErrors: false` (default): Operations return `undefined`/`null` on errors
263
+ - When `throwOnErrors: true`: Operations throw errors for connection issues, etc.
264
+
265
+ - **Read-Through Operations** (`rt.run`, `rt.send`, `rt.wrap`, `rt.exec`):
266
+ - Never throw errors, regardless of `throwOnErrors` setting
267
+ - Include `cacheErrors` array in response when errors occur
268
+ - Always fetch from remote service when cache operations fail
269
+ - Log errors for monitoring and debugging
270
+
256
271
  #### Environment-Specific Configuration
257
272
 
258
273
  You can override settings for different environments:
@@ -318,6 +333,7 @@ cds-caching provides 3 storage options:
318
333
  - Works across multiple app instances, making it ideal for scalable applications
319
334
  - Available on SAP BTP via hyperscaler options (e.g., AWS, Azure, Google Cloud)
320
335
  - Even trial accounts provide Redis access
336
+ - Redis will be non-blocking
321
337
 
322
338
  #### Redis Development Setup
323
339
 
@@ -431,6 +447,31 @@ await cache.delete("bp:1000001")
431
447
  await cache.clear()
432
448
  ```
433
449
 
450
+ **Error Handling for Basic Operations:**
451
+
452
+ ```javascript
453
+ // With throwOnErrors: false (default)
454
+ try {
455
+ const value = await cache.get("bp:1000001")
456
+ if (value === undefined) {
457
+ // Handle cache miss or error
458
+ console.log("Value not found or cache error occurred")
459
+ }
460
+ } catch (error) {
461
+ // Only thrown for non-cache related errors
462
+ console.error("Unexpected error:", error)
463
+ }
464
+
465
+ // With throwOnErrors: true
466
+ try {
467
+ const value = await cache.get("bp:1000001")
468
+ // Value will be undefined if not found, but errors will be thrown
469
+ } catch (error) {
470
+ // Errors thrown for connection issues, etc.
471
+ console.error("Cache error:", error)
472
+ }
473
+ ```
474
+
434
475
  #### 2. CQN Query Caching
435
476
 
436
477
  For more advanced CAP integration, cache CAP's CQN queries directly. By passing in the query, a dynamic key is generated based on the CQN structure of the query. Note, that passing in queries with dynamic parameters (e.g. `SELECT.from(Foo).where({id: 1})`) will result in a different key for each query execution.
@@ -463,6 +504,23 @@ Because the cache key has been dynamically created at runtime, it will also be r
463
504
  const { result, cacheKey } = await cache.rt.run(query, db)
464
505
  ```
465
506
 
507
+ **Error Handling for Read-Through Operations:**
508
+
509
+ Read-through operations never throw errors, even when cache operations fail. Instead, they include error information in the response:
510
+
511
+ ```javascript
512
+ // Read-through operations always return a result, even on cache errors
513
+ const { result, cacheKey, metadata, cacheErrors } = await cache.rt.run(query, db)
514
+
515
+ if (cacheErrors && cacheErrors.length > 0) {
516
+ console.log("Cache errors occurred:", cacheErrors)
517
+ // Result will be fetched from remote service despite cache errors
518
+ }
519
+
520
+ // The result is always available, regardless of cache errors
521
+ return result
522
+ ```
523
+
466
524
  #### 3. RemoteService Request-Level Caching
467
525
 
468
526
  Cache entire CAP requests with context awareness (e.g. user, tenant, locale, etc.), which is useful for caching slow remote service calls or even application services. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale (if not configured otherwise).
@@ -770,6 +828,36 @@ for await (const entry of iterator) {
770
828
 
771
829
  This will return an iterator over all cache entries. You can use this to traverse all cache entries and invalidate them based on a specific condition. You should only use this for small caches (e.g. by using multiple caching services with different namespaces).
772
830
 
831
+ ### TypeScript Support
832
+
833
+ cds-caching includes comprehensive TypeScript definitions. The library is written in JavaScript but provides full TypeScript support for better development experience.
834
+
835
+ #### Basic Usage with TypeScript
836
+
837
+ ```typescript
838
+ import { CachingService, CacheOptions, ReadThroughResult } from 'cds-caching';
839
+
840
+ const cache = await cds.connect.to('caching') as CachingService;
841
+
842
+ // Basic cache operations
843
+ await cache.set('my-key', { data: 'value' }, { ttl: 3600 });
844
+ const value = await cache.get('my-key');
845
+
846
+ // Read-through operations with full type safety
847
+ const { result, cacheKey, metadata } = await cache.rt.send(request, service, {
848
+ ttl: 1800,
849
+ tags: ['user-data']
850
+ });
851
+
852
+ // Function wrapping with type inference
853
+ const cachedFunction = cache.rt.wrap('expensive-operation', async (id: string) => {
854
+ return await this.performExpensiveOperation(id);
855
+ });
856
+
857
+ const { result: operationResult } = await cachedFunction('user-123');
858
+
859
+ ```
860
+
773
861
  ### OData Service Caching Considerations
774
862
 
775
863
  While caching individual requests can improve performance, **caching an entire OData service is generally not recommended**. Here's why:
@@ -16,7 +16,9 @@ class CachingService extends cds.Service {
16
16
  this.options = this.options || {
17
17
  store: null,
18
18
  compression: null,
19
- credentials: {}
19
+ credentials: {},
20
+ namespace: null,
21
+ throwOnErrors: false,
20
22
  };
21
23
 
22
24
  // Initialize managers
@@ -164,7 +166,8 @@ class CachingService extends cds.Service {
164
166
  if (typeof args[0] !== "object" || !args[1].send || typeof args[1] !== "object" || args[0].method !== "GET") {
165
167
  return super.send(...args);
166
168
  } else {
167
- return (await this.rt.send(...args)).result;
169
+ const result = await this.rt.send(...args);
170
+ return result.result;
168
171
  }
169
172
  }
170
173
 
@@ -177,7 +180,8 @@ class CachingService extends cds.Service {
177
180
  if (typeof args[0] !== "object" && !["Request", "NoaRequest", "ODataRequest", "cds.ql"].includes(args[0].constructor.name)) {
178
181
  return super.run(...args);
179
182
  } else {
180
- return (await this.rt.run(...args)).result;
183
+ const result = await this.rt.run(...args);
184
+ return result.result;
181
185
  }
182
186
  }
183
187
 
@@ -7,6 +7,36 @@ class AsyncOperations {
7
7
  this.keyManager = keyManager;
8
8
  this.statistics = statistics;
9
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
+ }
10
40
  }
11
41
 
12
42
  /**
@@ -85,25 +115,91 @@ class AsyncOperations {
85
115
  cacheOptions: JSON.stringify(options)
86
116
  };
87
117
 
88
- if (await this.cache.has(cacheKey)) {
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) {
89
129
  const latency = this.getElapsedMs(startTime);
90
- this.statistics.recordHit(latency, cacheKey, metadata);
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
+ }
91
157
 
92
- const result = await this.cache.send("GET", { key: cacheKey });
93
- return { result: result?.value || result, cacheKey, metadata: { hit: true, latency } };
94
- } else {
158
+ // Cache miss or cache error - delegate to underlying function
159
+ try {
95
160
  const response = await asyncFunction(...args);
96
161
  const latency = this.getElapsedMs(startTime);
97
- this.statistics.recordMiss(latency, cacheKey, metadata);
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
+ }
98
172
 
173
+ // Safely store in cache
99
174
  const wrappedValue = {
100
175
  value: response,
101
176
  tags: options.tags || [],
102
177
  timestamp: Date.now()
103
178
  };
104
- await this.cache.send("SET", { key: cacheKey, value: wrappedValue, ttl: options.ttl || 0 });
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
+ }
105
188
 
106
- return { result: response, cacheKey, metadata: { hit: false, latency } };
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;
107
203
  }
108
204
  }
109
205
  }
@@ -137,24 +233,91 @@ class AsyncOperations {
137
233
  cacheOptions: JSON.stringify(options)
138
234
  };
139
235
 
140
- if (await this.cache.has(cacheKey)) {
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) {
141
247
  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 {
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 {
146
278
  const response = await asyncFunction(...args);
147
279
  const latency = this.getElapsedMs(startTime);
148
- this.statistics.recordMiss(latency, cacheKey, metadata);
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
+ }
149
290
 
291
+ // Safely store in cache
150
292
  const wrappedValue = {
151
293
  value: response,
152
294
  tags: options.tags || [],
153
295
  timestamp: Date.now()
154
296
  };
155
- await this.cache.send("SET", { key: cacheKey, value: wrappedValue, ttl: options.ttl || 0 });
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
+ }
156
306
 
157
- return { result: response, cacheKey, metadata: { hit: false, latency } };
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;
158
321
  }
159
322
  }
160
323
 
@@ -70,7 +70,15 @@ class BasicOperations {
70
70
  */
71
71
  async has(key) {
72
72
  const createdKey = this.keyManager.createKey(key);
73
- return this.cache.cache.has(createdKey);
73
+ try {
74
+ return await this.cache.cache.has(createdKey);
75
+ } catch (error) {
76
+ if (this.cache.options.throwOnErrors) {
77
+ throw error;
78
+ } else {
79
+ return false; // We don't want to throw errors here
80
+ }
81
+ }
74
82
  }
75
83
 
76
84
  /**
@@ -101,7 +109,9 @@ class BasicOperations {
101
109
  * @returns {Promise<void>}
102
110
  */
103
111
  async clear() {
104
- await this.cache.send('CLEAR');
112
+ await this.cache.send('CLEAR', {
113
+ deleteAll: true
114
+ });
105
115
 
106
116
  // Record the native clear operation
107
117
  const metadata = {
@@ -15,9 +15,39 @@ class CapOperations {
15
15
  this.statistics = statistics;
16
16
  this.tagResolver = new TagResolver();
17
17
  this.runtimeConfigManager = runtimeConfigManager;
18
+ this.log = log || console;
18
19
  }
19
20
 
20
-
21
+ /**
22
+ * Safely execute cache operations with error handling
23
+ * @param {Function} operation - The cache operation to execute
24
+ * @param {string} operationName - Name of the operation for logging
25
+ * @param {object} context - Context information for logging
26
+ * @returns {Promise<object>} - The result with error information
27
+ */
28
+ async safeCacheOperation(operation, operationName, context = {}) {
29
+ try {
30
+ const result = await operation();
31
+ this.log.info('REEEESULT', { result });
32
+ return { success: true, result, error: null };
33
+ } catch (error) {
34
+ this.log.warn(`Cache ${operationName} failed:`, {
35
+ error: error.message,
36
+ stack: error.stack,
37
+ context: context
38
+ });
39
+
40
+ return {
41
+ success: false,
42
+ result: null,
43
+ error: {
44
+ message: error.message,
45
+ operation: operationName,
46
+ context: context
47
+ }
48
+ };
49
+ }
50
+ }
21
51
 
22
52
  /**
23
53
  * Send a request with caching with read-through capabilities.
@@ -34,7 +64,7 @@ class CapOperations {
34
64
  }
35
65
 
36
66
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method?.toUpperCase()) || !service.send) {
37
- return { result: null, cacheKey: null, metadata: { hit: false, latency: 0 } };
67
+ return { result: null, cacheKey: null, metadata: { hit: false, latency: 0 }, cacheErrors: [] };
38
68
  }
39
69
 
40
70
  const keyParts = {
@@ -79,31 +109,94 @@ class CapOperations {
79
109
  cacheOptions: JSON.stringify(requestOptions)
80
110
  };
81
111
 
82
- if (await this.cache.has(key)) {
112
+ // Safely check if key exists in cache
113
+ const hasKeyResult = await this.safeCacheOperation(
114
+ () => this.cache.has(key),
115
+ 'has',
116
+ { key, serviceName: service.name }
117
+ );
118
+
119
+ const hasKey = hasKeyResult.success && hasKeyResult.result;
120
+ const cacheErrors = [];
121
+
122
+ if (hasKey) {
83
123
  const latency = this.getElapsedMs(startTime);
84
124
 
85
- this.statistics.recordHit(latency, key, metadata);
125
+ // Safely record hit statistics
126
+ const hitStatsResult = await this.safeCacheOperation(
127
+ () => this.statistics.recordHit(latency, key, metadata),
128
+ 'recordHit',
129
+ { key, latency }
130
+ );
131
+ if (!hitStatsResult.success) {
132
+ cacheErrors.push(hitStatsResult.error);
133
+ }
86
134
 
87
- const wrappedValue = await this.cache.send("GET", { key });
88
- return { result: wrappedValue?.value, cacheKey: key, metadata: { hit: true, latency: latency } };
89
- } else {
135
+ // Safely get value from cache
136
+ const getResult = await this.safeCacheOperation(
137
+ () => this.cache.send("GET", { key }),
138
+ 'get',
139
+ { key }
140
+ );
141
+
142
+ if (getResult.success && getResult.result?.value !== undefined) {
143
+ return {
144
+ result: getResult.result.value,
145
+ cacheKey: key,
146
+ metadata: { hit: true, latency: latency },
147
+ cacheErrors: cacheErrors
148
+ };
149
+ }
150
+ }
151
+
152
+ // Cache miss or cache error - delegate to underlying service
153
+ try {
90
154
  const response = await service.send(request);
91
155
  const totalLatency = this.getElapsedMs(startTime);
92
- this.statistics.recordMiss(totalLatency, key, metadata);
156
+
157
+ // Safely record miss statistics
158
+ const missStatsResult = await this.safeCacheOperation(
159
+ () => this.statistics.recordMiss(totalLatency, key, metadata),
160
+ 'recordMiss',
161
+ { key, latency: totalLatency }
162
+ );
163
+ if (!missStatsResult.success) {
164
+ cacheErrors.push(missStatsResult.error);
165
+ }
93
166
 
167
+ // Safely store in cache
94
168
  const wrappedValue = {
95
169
  value: response,
96
170
  tags: this.tagResolver.resolveTags(requestOptions.tags, response, { ...request.params, user: request.user?.id, tenant: request.tenant, locale: request.locale, hash: this.keyManager.createContentHash(request) }),
97
171
  timestamp: Date.now()
98
172
  };
99
- await this.cache.send("SET", { key, value: wrappedValue, ttl: requestOptions.ttl || 0 });
173
+
174
+ const setResult = await this.safeCacheOperation(
175
+ () => this.cache.send("SET", { key, value: wrappedValue, ttl: requestOptions.ttl || 0 }),
176
+ 'set',
177
+ { key, ttl: requestOptions.ttl }
178
+ );
179
+ if (!setResult.success) {
180
+ cacheErrors.push(setResult.error);
181
+ }
100
182
 
101
- return { result: response, cacheKey: key, metadata: { hit: false, latency: totalLatency } };
183
+ return {
184
+ result: response,
185
+ cacheKey: key,
186
+ metadata: { hit: false, latency: totalLatency },
187
+ cacheErrors: cacheErrors
188
+ };
189
+ } catch (serviceError) {
190
+ // If the underlying service fails, throw the error
191
+ this.log.error('Service operation failed:', {
192
+ error: serviceError.message,
193
+ serviceName: service.name,
194
+ key: key
195
+ });
196
+ throw serviceError;
102
197
  }
103
198
  }
104
199
 
105
-
106
-
107
200
  /**
108
201
  * Run a cached operation with automatic key generation
109
202
  * @param {object} req - the request object
@@ -133,34 +226,87 @@ class CapOperations {
133
226
 
134
227
  // Track cache operation timing
135
228
  const startTime = process.hrtime();
136
- const wrappedValue = await this.cache.send("GET", { key: req.cacheKey });
137
- const cacheHit = wrappedValue?.value !== undefined;
229
+
230
+ // Safely get value from cache
231
+ const getResult = await this.safeCacheOperation(
232
+ () => this.cache.send("GET", { key: req.cacheKey }),
233
+ 'get',
234
+ { key: req.cacheKey, serviceName: req.target?.name }
235
+ );
236
+
237
+ const cacheHit = getResult.success && getResult.result?.value !== undefined;
138
238
  const cacheLatency = this.getElapsedMs(startTime);
139
239
  const metadata = this.extractMetadataFromRequest(req);
240
+ const cacheErrors = [];
140
241
 
141
242
  if (cacheHit) {
142
243
  // Cache hit
143
- this.statistics.recordHit(cacheLatency, req.cacheKey, metadata);
244
+ const hitStatsResult = await this.safeCacheOperation(
245
+ () => this.statistics.recordHit(cacheLatency, req.cacheKey, metadata),
246
+ 'recordHit',
247
+ { key: req.cacheKey, latency: cacheLatency }
248
+ );
249
+ if (!hitStatsResult.success) {
250
+ cacheErrors.push(hitStatsResult.error);
251
+ }
252
+
144
253
  req.res?.setHeader('x-sap-cap-cache', "hit");
145
-
146
- const wrappedValue = await this.cache.send("GET", { key: req.cacheKey });
147
- return { result: wrappedValue?.value, cacheKey: req.cacheKey, metadata: { hit: true, latency: cacheLatency } };
254
+ return {
255
+ result: getResult.result.value,
256
+ cacheKey: req.cacheKey,
257
+ metadata: { hit: true, latency: cacheLatency },
258
+ cacheErrors: cacheErrors
259
+ };
148
260
  } else {
149
261
  // Cache miss - track the backend operation
150
- const response = await next();
151
- const totalLatency = this.getElapsedMs(startTime);
152
-
153
- // Track the miss with total latency (cache lookup + backend operation)
154
- this.statistics.recordMiss(totalLatency, req.cacheKey, metadata);
155
- req.res?.setHeader('x-sap-cap-cache', "miss");
262
+ try {
263
+ const response = await next();
264
+ const totalLatency = this.getElapsedMs(startTime);
156
265
 
157
- const wrappedValue = {
158
- value: response,
159
- tags: this.tagResolver.resolveTags(req.cacheOptions.tags, response, { ...req.params, hash: this.keyManager.createContentHash(req) }),
160
- timestamp: Date.now()
161
- };
162
- await this.cache.send("SET", { key: req.cacheKey, value: wrappedValue, ttl: req.cacheOptions.ttl || 0 });
163
- return { result: response, cacheKey: req.cacheKey, metadata: { hit: false, latency: totalLatency } };
266
+ // Safely record miss statistics
267
+ const missStatsResult = await this.safeCacheOperation(
268
+ () => this.statistics.recordMiss(totalLatency, req.cacheKey, metadata),
269
+ 'recordMiss',
270
+ { key: req.cacheKey, latency: totalLatency }
271
+ );
272
+ if (!missStatsResult.success) {
273
+ cacheErrors.push(missStatsResult.error);
274
+ }
275
+
276
+ req.res?.setHeader('x-sap-cap-cache', "miss");
277
+
278
+ // Safely store in cache
279
+ const wrappedValue = {
280
+ value: response,
281
+ tags: this.tagResolver.resolveTags(req.cacheOptions.tags, response, { ...req.params, hash: this.keyManager.createContentHash(req) }),
282
+ timestamp: Date.now()
283
+ };
284
+
285
+ const setResult = await this.safeCacheOperation(
286
+ () => this.cache.send("SET", { key: req.cacheKey, value: wrappedValue, ttl: req.cacheOptions.ttl || 0 }),
287
+ 'set',
288
+ { key: req.cacheKey, ttl: req.cacheOptions.ttl }
289
+ );
290
+
291
+ if (!setResult.success) {
292
+ cacheErrors.push(setResult.error);
293
+ }
294
+
295
+ return {
296
+ result: response,
297
+ cacheKey: req.cacheKey,
298
+ metadata: { hit: false, latency: totalLatency },
299
+ cacheErrors: cacheErrors
300
+ };
301
+ } catch (serviceError) {
302
+ // If the underlying service fails, throw the error
303
+ this.log.error('Service operation failed:', {
304
+ error: serviceError.message,
305
+ serviceName: req.target?.name,
306
+ key: req.cacheKey
307
+ });
308
+ throw serviceError;
309
+ }
164
310
  }
165
311
  case "cds.ql":
166
312
  const query = arg1;
@@ -177,7 +323,15 @@ class CapOperations {
177
323
 
178
324
  // Track cache operation timing
179
325
  const startTime = process.hrtime();
180
- const hasCachedValue = await this.cache.has(query.cacheKey);
326
+
327
+ // Safely check if key exists in cache
328
+ const hasCachedValueResult = await this.safeCacheOperation(
329
+ () => this.cache.has(query.cacheKey),
330
+ 'has',
331
+ { key: query.cacheKey, serviceName: srv?.name }
332
+ );
333
+
334
+ const hasCachedValue = hasCachedValueResult.success && hasCachedValueResult.result;
181
335
  const cacheLatency = this.getElapsedMs(startTime);
182
336
  const metadata = {
183
337
  dataType: 'Query',
@@ -197,35 +351,89 @@ class CapOperations {
197
351
  }),
198
352
  cacheOptions: JSON.stringify(options)
199
353
  };
354
+ const cacheErrors = [];
200
355
 
201
356
  if (hasCachedValue) {
202
357
  // Cache hit
203
- this.statistics.recordHit(cacheLatency, query.cacheKey, metadata);
358
+ const hitStatsResult = await this.safeCacheOperation(
359
+ () => this.statistics.recordHit(cacheLatency, query.cacheKey, metadata),
360
+ 'recordHit',
361
+ { key: query.cacheKey, latency: cacheLatency }
362
+ );
363
+ if (!hitStatsResult.success) {
364
+ cacheErrors.push(hitStatsResult.error);
365
+ }
366
+
367
+ const getResult = await this.safeCacheOperation(
368
+ () => this.cache.send("GET", { key: query.cacheKey }),
369
+ 'get',
370
+ { key: query.cacheKey }
371
+ );
372
+
373
+ if (getResult.success && getResult.result?.value !== undefined) {
374
+ return {
375
+ result: getResult.result.value,
376
+ cacheKey: query.cacheKey,
377
+ metadata: { hit: true, latency: cacheLatency },
378
+ cacheErrors: cacheErrors
379
+ };
380
+ }
381
+ }
204
382
 
205
- const wrappedValue = await this.cache.send("GET", { key: query.cacheKey });
206
- return { result: wrappedValue?.value, cacheKey: query.cacheKey, metadata: { hit: true, latency: cacheLatency } };
207
- } else {
208
- // Cache miss
383
+ // Cache miss or cache error
384
+ try {
209
385
  const data = await srv.run(query);
210
386
  const totalLatency = this.getElapsedMs(startTime);
211
387
 
212
- // Track the miss with total latency (cache lookup + backend operation)
213
- this.statistics.recordMiss(totalLatency, query.cacheKey, metadata);
214
-
388
+ // Safely record miss statistics
389
+ const missStatsResult = await this.safeCacheOperation(
390
+ () => this.statistics.recordMiss(totalLatency, query.cacheKey, metadata),
391
+ 'recordMiss',
392
+ { key: query.cacheKey, latency: totalLatency }
393
+ );
394
+ if (!missStatsResult.success) {
395
+ cacheErrors.push(missStatsResult.error);
396
+ }
397
+
398
+ // Safely store in cache
215
399
  const wrappedValue = {
216
400
  value: data,
217
401
  tags: this.tagResolver.resolveTags(options.tags, data, { ...query.params, hash: this.keyManager.createKey(query, { serviceName: srv?.name, template: '{hash}' }) }),
218
402
  timestamp: Date.now()
219
403
  };
220
- await this.cache.send("SET", { key: query.cacheKey, value: wrappedValue, ttl: options.ttl || 0 });
221
- return { result: data, cacheKey: query.cacheKey, metadata: { hit: false, latency: totalLatency } };
404
+
405
+ const setResult = await this.safeCacheOperation(
406
+ () => this.cache.send("SET", { key: query.cacheKey, value: wrappedValue, ttl: options.ttl || 0 }),
407
+ 'set',
408
+ { key: query.cacheKey, ttl: options.ttl }
409
+ );
410
+ if (!setResult.success) {
411
+ cacheErrors.push(setResult.error);
412
+ }
413
+
414
+ this.log.info('REEEESULT', { setResult, cacheErrors, wrappedValue });
415
+
416
+ return {
417
+ result: data,
418
+ cacheKey: query.cacheKey,
419
+ metadata: { hit: false, latency: totalLatency },
420
+ cacheErrors: cacheErrors
421
+ };
422
+ } catch (serviceError) {
423
+ // If the underlying service fails, throw the error
424
+ this.log.error('Service operation failed:', {
425
+ error: serviceError.message,
426
+ serviceName: srv?.name,
427
+ key: query.cacheKey
428
+ });
429
+ throw serviceError;
222
430
  }
223
431
  } else {
224
432
  return srv.run(query);
225
433
  }
226
434
  }
227
435
  }
228
- return null;
436
+ return { result: null, cacheKey: null, metadata: { hit: false, latency: 0 }, cacheErrors: [] };
229
437
  }
230
438
 
231
439
  /**
@@ -10,6 +10,7 @@ const { default: KeyvGzip } = require('@keyv/compress-gzip');
10
10
  class CacheStoreManager {
11
11
  constructor(options = {}) {
12
12
  this.options = options;
13
+ this.log = cds.log('cds-caching')
13
14
  }
14
15
 
15
16
  /**
@@ -22,10 +23,11 @@ class CacheStoreManager {
22
23
  const store = this._createStoreInstance(options);
23
24
  const cacheOptions = this._createCacheOptions(options, cacheName);
24
25
  const cache = new Keyv(cacheOptions);
26
+ cache.throwOnErrors = options.throwOnErrors;
25
27
 
26
28
  // Set up error handling
27
29
  cache.on('error', err => {
28
- console.error(`Cache error for ${cacheName}:`, err);
30
+ this.log.error(`Cache error for ${cacheName}:`, err);
29
31
  });
30
32
 
31
33
  // Set up cleanup function
@@ -47,10 +49,25 @@ class CacheStoreManager {
47
49
  busyTimeout: options.credentials?.busyTimeout || 10000
48
50
  });
49
51
  case "redis":
50
- return new KeyvRedis({
52
+ const store = new KeyvRedis({
51
53
  ...options.credentials,
52
54
  ...(options.credentials?.uri ? { url: options.credentials?.uri } : {}),
55
+ ...{ throwOnConnectErrors: options.throwOnErrors, useKeyPrefix: false, throwOnErrors: options.throwOnErrors }
53
56
  });
57
+
58
+ if (options.throwOnErrors) {
59
+ store.throwOnConnectErrors = false; // We want to handle the errors ourselves
60
+ store.throwOnErrors = true;
61
+ const redisClient = store.client;
62
+ if (redisClient.options) {
63
+ redisClient.options.disableOfflineQueue = true;
64
+ if (redisClient.options.socket) {
65
+ redisClient.options.socket.reconnectStrategy = false;
66
+ }
67
+ }
68
+ }
69
+
70
+ return store;
54
71
  default:
55
72
  return new Map();
56
73
  }
@@ -93,7 +110,7 @@ class CacheStoreManager {
93
110
  try {
94
111
  await store.disconnect();
95
112
  } catch (err) {
96
- console.error(`Error disconnecting from store for ${cacheName}:`, err);
113
+ this.log.error(`Error disconnecting from store for ${cacheName}:`, err);
97
114
  }
98
115
  }
99
116
  };
@@ -103,7 +103,8 @@ class RuntimeConfigurationManager {
103
103
  return {
104
104
  metricsEnabled: cacheConfig?.metricsEnabled === true || cacheConfig?.metricsEnabled === 1 || false,
105
105
  keyMetricsEnabled: cacheConfig?.keyMetricsEnabled === true || cacheConfig?.keyMetricsEnabled === 1 || false,
106
- keyManagement
106
+ keyManagement,
107
+ throwOnErrors: this.options.throwOnErrors
107
108
  };
108
109
  } catch (error) {
109
110
  this.log.warn(`Failed to get runtime configuration for cache ${this.cacheName}:`, error);
@@ -114,7 +115,8 @@ class RuntimeConfigurationManager {
114
115
  isUserAware: false,
115
116
  isTenantAware: false,
116
117
  isLocaleAware: false
117
- }
118
+ },
119
+ throwOnErrors: this.options.throwOnErrors
118
120
  };
119
121
  }
120
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cds-caching",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A caching plugin for SAP CAP applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,17 +44,18 @@
44
44
  ],
45
45
  "dependencies": {
46
46
  "@keyv/compress-gzip": "^2.0.3",
47
- "@keyv/compress-lz4": "^1.0.0",
48
- "@keyv/redis": "^4.5.0",
47
+ "@keyv/compress-lz4": "^1.0.1",
48
+ "@keyv/redis": "^5.0.0",
49
49
  "@keyv/sqlite": "^4.0.5",
50
- "keyv": "^5.3.4"
50
+ "keyv": "^5.4.0"
51
51
  },
52
52
  "devDependencies": {
53
- "eslint": "^9.30.1",
53
+ "@cap-js/cds-test": "^0.4.0",
54
+ "@release-it/conventional-changelog": "^10.0.1",
55
+ "eslint": "^9.32.0",
54
56
  "husky": "^9.1.7",
55
- "jest": "^30.0.4",
56
- "release-it": "^19.0.3",
57
- "@cap-js/cds-test": "^0.4.0"
57
+ "jest": "^30.0.5",
58
+ "release-it": "^19.0.4"
58
59
  },
59
60
  "engines": {
60
61
  "node": ">=20"