cds-caching 0.2.1 → 0.3.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 +57 -14
- package/package.json +8 -9
- package/srv/CachingService.js +110 -101
package/README.md
CHANGED
|
@@ -26,12 +26,12 @@ cds-caching is specifically designed for efficient caching, not data replication
|
|
|
26
26
|
### Key Features
|
|
27
27
|
|
|
28
28
|
* **Flexible Key-Value Store** – Store and retrieve data using simple key-based access.
|
|
29
|
-
* **CachingService** – A
|
|
29
|
+
* **CachingService** – A cds.Service implementation with an intuitive API for seamless integration into CAP.
|
|
30
30
|
* **Event Handling** – Monitor and react to cache events, such as before/after storage and retrieval.
|
|
31
31
|
* **CAP-specific Caching** – Effortlessly cache CQN queries or CAP cds.Requests using code or the @cache annotation.
|
|
32
32
|
* **TTL Support** – Automatically manage data expiration with configurable time-to-live (TTL) settings.
|
|
33
33
|
* **Tag Support** – Use dynamic tags for flexible cache invalidation options.
|
|
34
|
-
* **Pluggable Storage Options** – Choose between in-memory caching or Redis.
|
|
34
|
+
* **Pluggable Storage Options** – Choose between in-memory caching, SQLite or Redis.
|
|
35
35
|
* **Compression** – Compress cached data to save memory using LZ4 or GZIP.
|
|
36
36
|
* **Integrated Statistics** – Monitor cache performance with hit rates, latencies, and more.
|
|
37
37
|
|
|
@@ -74,13 +74,20 @@ For more control, you can specify additional options:
|
|
|
74
74
|
"caching": {
|
|
75
75
|
"impl": "cds-caching",
|
|
76
76
|
"namespace": "my::app::caching",
|
|
77
|
-
"store": "in-memory", // "in-memory" or "redis"
|
|
77
|
+
"store": "in-memory", // "in-memory" or "sqlite" or "redis"
|
|
78
78
|
"compression": "lz4", // "lz4" or "gzip"
|
|
79
|
-
"credentials": { // if store is redis
|
|
79
|
+
"credentials": { // if store is redis or sqlite
|
|
80
|
+
|
|
81
|
+
// Redis specific
|
|
80
82
|
"host": "localhost",
|
|
81
83
|
"port": 6379,
|
|
82
84
|
"password": "optional",
|
|
83
85
|
"url": "redis://..." // Alternative: Redis connection URI
|
|
86
|
+
|
|
87
|
+
// SQLite specific
|
|
88
|
+
"url": "sqlite://./cache.sqlite"
|
|
89
|
+
"table": "cache",
|
|
90
|
+
"busyTimeout": 10000
|
|
84
91
|
},
|
|
85
92
|
"statistics": {
|
|
86
93
|
"enabled": true,
|
|
@@ -98,12 +105,19 @@ For more control, you can specify additional options:
|
|
|
98
105
|
|
|
99
106
|
#### Storage Options
|
|
100
107
|
|
|
101
|
-
cds-caching provides
|
|
108
|
+
cds-caching provides 3 storage options:
|
|
102
109
|
|
|
103
|
-
##### In-Memory Cache (for small-scale
|
|
110
|
+
##### In-Memory Cache (for development / small-scale uses)
|
|
104
111
|
- Simple and fast, but not persistent
|
|
105
112
|
- Not suitable for production since Node.js runtime memory is limited
|
|
106
113
|
- Data is lost when the application restarts
|
|
114
|
+
- Memory on SAP BTP Cloud Foundry is limited (up to 16 GB) and produces costs
|
|
115
|
+
|
|
116
|
+
##### SQLite (for medium-size use uses)
|
|
117
|
+
- Data is stored in local SQLite database
|
|
118
|
+
- Data is persited next to SAP BTP application with disk-quota up to 10 GB
|
|
119
|
+
- Cache will be removed after each deployment to SAP BTP
|
|
120
|
+
- No distributed cache between application instances (horizontal scaling)
|
|
107
121
|
|
|
108
122
|
##### Redis Cache (recommended for production)
|
|
109
123
|
- Persistent and supports distributed caching
|
|
@@ -111,7 +125,7 @@ cds-caching provides two storage options:
|
|
|
111
125
|
- Available on SAP BTP via hyperscaler options (e.g., AWS, Azure, Google Cloud)
|
|
112
126
|
- Even trial accounts provide Redis access
|
|
113
127
|
|
|
114
|
-
#### Development Setup
|
|
128
|
+
#### Redis Development Setup
|
|
115
129
|
|
|
116
130
|
##### Running Redis Locally via Docker
|
|
117
131
|
For local development, Redis can be quickly set up using Docker. A simple docker-compose configuration provides a lightweight caching environment:
|
|
@@ -237,11 +251,12 @@ const result = await cache.run(query, db)
|
|
|
237
251
|
|
|
238
252
|
This will transparently cache the result of the query and return the cached result if available for all further requests.
|
|
239
253
|
|
|
240
|
-
#### 3. Request-Level Caching
|
|
254
|
+
#### 3. RemoteService Request-Level Caching
|
|
241
255
|
|
|
242
|
-
Cache entire CAP requests with context awareness (e.g. user, tenant, locale, etc.), which is useful for caching slow remote service calls. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
|
|
256
|
+
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.
|
|
243
257
|
|
|
244
258
|
```javascript
|
|
259
|
+
// Cache the requests to an exposed external entity
|
|
245
260
|
this.on('READ', BusinessPartners, async (req, next) => {
|
|
246
261
|
const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
|
|
247
262
|
let value = await cache.get(req)
|
|
@@ -256,17 +271,45 @@ this.on('READ', BusinessPartners, async (req, next) => {
|
|
|
256
271
|
Alternatively use read-through caching via the `run` method to let the caching service handle the caching transparently:
|
|
257
272
|
|
|
258
273
|
```javascript
|
|
259
|
-
|
|
260
|
-
const
|
|
274
|
+
this.on('READ', BusinessPartners, async (req, next) => {
|
|
275
|
+
const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
|
|
276
|
+
return await cache.run(req, bupa)
|
|
277
|
+
})
|
|
278
|
+
|
|
261
279
|
```
|
|
262
280
|
|
|
263
281
|
This will transparently cache the result of the request and return the cached result if available for all further requests.
|
|
264
282
|
|
|
265
|
-
|
|
283
|
+
### 4. ApplicationService Request-Level Caching
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
> Caching an entire entity should be used with caution, as it will cache all permutations of requests ($filter, $expand, $orderby, etc.) on the entity, which will lead to a huge number of cache entries. Use this only for entities where you can guarantee a low number of different queries.
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
But not only external services can be cached, it's also possible to cache requests against an ApplicationService.
|
|
290
|
+
Here, you should make use of the [`prepend`](https://cap.cloud.sap/docs/node.js/core-services#srv-prepend) function, to register the `on` handler before the default handler. Thus, it is possible to first check for the cache entries and only execute the default behavior if necessary.
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
```javascript
|
|
294
|
+
class MyService extends cds.ApplicationService {
|
|
295
|
+
async init() {
|
|
296
|
+
|
|
297
|
+
// Read-through caching for the full entity
|
|
298
|
+
this.prepend(() => {
|
|
299
|
+
const { MyEntity } = this.entities;
|
|
300
|
+
this.on('READ', MyEntity, async (req, next) => {
|
|
301
|
+
const cache = cds.connect.to("caching");
|
|
302
|
+
return cache.run(req, next);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
return super.init()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
266
309
|
|
|
267
|
-
|
|
310
|
+
#### 5. ApplicationService Request-Level Caching with Annotations
|
|
268
311
|
|
|
269
|
-
|
|
312
|
+
Alternatively to doing this via code, you can use annotations to enable caching on service entities or OData functions. The caching service will automatically generate a key for the request based on the request object and the current user, tenant and locale.
|
|
270
313
|
|
|
271
314
|
```
|
|
272
315
|
service MyService {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cds-caching",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A caching plugin for SAP CAP applications supporting Redis",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -41,17 +41,16 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@keyv/compress-gzip": "^2.0.2",
|
|
43
43
|
"@keyv/compress-lz4": "^1.0.0",
|
|
44
|
-
"@keyv/redis": "^4.
|
|
45
|
-
"keyv": "^
|
|
44
|
+
"@keyv/redis": "^4.3.1",
|
|
45
|
+
"@keyv/sqlite": "^4.0.1",
|
|
46
|
+
"keyv": "^5.3.1"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"chai-subset": "^1.6.0",
|
|
51
|
-
"eslint": "^8.57.0",
|
|
52
|
-
"husky": "^9.0.11",
|
|
49
|
+
"eslint": "^9.21.0",
|
|
50
|
+
"husky": "^9.1.7",
|
|
53
51
|
"jest": "^29.7.0",
|
|
54
|
-
"release-it": "^18.1.2"
|
|
52
|
+
"release-it": "^18.1.2",
|
|
53
|
+
"@cap-js/cds-test": "^0.2.0"
|
|
55
54
|
},
|
|
56
55
|
"engines": {
|
|
57
56
|
"node": ">=16"
|
package/srv/CachingService.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const cds = require('@sap/cds');
|
|
2
2
|
const { Keyv } = require('keyv');
|
|
3
3
|
const { default: KeyvRedis } = require('@keyv/redis');
|
|
4
|
+
const { default: KeyvSqlite } = require('@keyv/sqlite');
|
|
4
5
|
const { default: KeyvLz4 } = require('@keyv/compress-lz4');
|
|
5
6
|
const { default: KeyvGzip } = require('@keyv/compress-gzip');
|
|
6
7
|
const crypto = require('crypto');
|
|
@@ -20,30 +21,45 @@ class CachingService extends cds.Service {
|
|
|
20
21
|
this.options = this.options || {
|
|
21
22
|
store: null,
|
|
22
23
|
compression: null,
|
|
23
|
-
credentials: {
|
|
24
|
-
}
|
|
24
|
+
credentials: { }
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
let store;
|
|
28
|
+
|
|
29
|
+
switch (this.options.store) {
|
|
30
|
+
case "sqlite":
|
|
31
|
+
store = new KeyvSqlite({
|
|
32
|
+
url: this.options.credentials?.url,
|
|
33
|
+
table: this.options.credentials?.table || 'cache',
|
|
34
|
+
busyTimeout: this.options.credentials?.busyTimeout || 10000
|
|
35
|
+
});
|
|
36
|
+
break;
|
|
37
|
+
case "redis":
|
|
38
|
+
store = new KeyvRedis({
|
|
39
|
+
...this.options.credentials,
|
|
40
|
+
// Redis, Hyperscaler Option on BTP provides a URI
|
|
41
|
+
...(this.options.credentials?.uri ? { url: this.options.credentials?.uri } : {}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
cds.once("shutdown", async () => {
|
|
45
|
+
if (this.cache.store?.disconnect) {
|
|
46
|
+
await this.cache.store.disconnect().catch((err) => {
|
|
47
|
+
this.LOG._error && this.LOG.error('Error disconnecting from Redis', err);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
store = new Map();
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
27
57
|
let cacheOptions = {
|
|
28
58
|
namespace: this.options.namespace || this.name,
|
|
29
|
-
|
|
30
|
-
...this.options.credentials,
|
|
31
|
-
// Redis, Hyperscaler Option on BTP provides a URI
|
|
32
|
-
...(this.options.credentials?.uri ? { url: this.options.credentials?.uri } : {}),
|
|
33
|
-
}) } : {}),
|
|
59
|
+
store: store,
|
|
34
60
|
compression: this.options.compression === "lz4" ? new KeyvLz4() : this.options.compression === "gzip" ? new KeyvGzip() : undefined
|
|
35
61
|
}
|
|
36
62
|
|
|
37
|
-
if (this.options.store === "redis") {
|
|
38
|
-
|
|
39
|
-
cds.once("shutdown", async () => {
|
|
40
|
-
if (this.cache.store?.disconnect) {
|
|
41
|
-
await this.cache.store.disconnect().catch((err) => {
|
|
42
|
-
this.LOG._error && this.LOG.error('Error disconnecting from Redis', err);
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
63
|
|
|
48
64
|
this.cache = new Keyv(cacheOptions);
|
|
49
65
|
this.LOG._info && this.LOG.info(`Caching service initialized with namespace ${cacheOptions.namespace}`);
|
|
@@ -218,8 +234,9 @@ class CachingService extends cds.Service {
|
|
|
218
234
|
req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
|
|
219
235
|
req.cacheKey = this.createKey(req, req.cacheOptions.key);
|
|
220
236
|
req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
|
|
221
|
-
|
|
222
|
-
|
|
237
|
+
const cachedValue = await this.get(req.cacheKey);
|
|
238
|
+
if (cachedValue) {
|
|
239
|
+
return cachedValue;
|
|
223
240
|
}
|
|
224
241
|
const response = await next();
|
|
225
242
|
req.cacheOptions.tags = this.resolveTags(req.cacheOptions.tags, response, req.params);
|
|
@@ -230,7 +247,7 @@ class CachingService extends cds.Service {
|
|
|
230
247
|
const srv = arguments[1];
|
|
231
248
|
|
|
232
249
|
if (query.SELECT) {
|
|
233
|
-
|
|
250
|
+
|
|
234
251
|
let options = {
|
|
235
252
|
ttl: 0,
|
|
236
253
|
tags: [],
|
|
@@ -277,7 +294,7 @@ class CachingService extends cds.Service {
|
|
|
277
294
|
async set(key, value, options = {}) {
|
|
278
295
|
const wrappedValue = {
|
|
279
296
|
value,
|
|
280
|
-
tags: options.tags || [],
|
|
297
|
+
tags: this.resolveTags(options.tags, value, options.params) || [],
|
|
281
298
|
timestamp: Date.now()
|
|
282
299
|
};
|
|
283
300
|
await this.send('SET', {
|
|
@@ -330,7 +347,7 @@ class CachingService extends cds.Service {
|
|
|
330
347
|
// Iterators
|
|
331
348
|
async *iterator() {
|
|
332
349
|
for await (const [key, value] of this.cache.iterator()) {
|
|
333
|
-
if (typeof value === "string") {
|
|
350
|
+
if (typeof value === "string") {
|
|
334
351
|
yield [key, JSON.parse(value)];
|
|
335
352
|
} else {
|
|
336
353
|
yield [key, value];
|
|
@@ -346,85 +363,77 @@ class CachingService extends cds.Service {
|
|
|
346
363
|
* @returns {string[]} Array of resolved tags
|
|
347
364
|
*/
|
|
348
365
|
resolveTags(tagConfigs = [], data, params = {}) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// Handle no data/params case
|
|
352
|
-
if (!data && !params) {
|
|
353
|
-
// Only process static tags when no sources are present
|
|
354
|
-
return tagConfigs
|
|
355
|
-
.filter(config => config.value)
|
|
356
|
-
.map(config => config.value);
|
|
357
|
-
}
|
|
366
|
+
// Handle empty/invalid configs
|
|
367
|
+
if (!tagConfigs?.length) return [];
|
|
358
368
|
|
|
359
|
-
// Convert data to array if single object
|
|
360
|
-
const dataArray = data ?
|
|
369
|
+
// Convert data to array if single object or string
|
|
370
|
+
const dataArray = !data ? [] :
|
|
371
|
+
Array.isArray(data) ? data :
|
|
372
|
+
typeof data === 'string' ? [data] : [data];
|
|
361
373
|
|
|
362
374
|
// Process each tag configuration
|
|
363
|
-
tagConfigs.
|
|
375
|
+
const resolvedTags = tagConfigs.flatMap(config => {
|
|
376
|
+
// Handle string tags
|
|
377
|
+
if (typeof config === 'string') {
|
|
378
|
+
return [config];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Handle invalid/empty config objects
|
|
382
|
+
if (!config || typeof config !== 'object') {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Handle static value tags
|
|
364
387
|
if (config.value) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
} else if (Array.isArray(config.data)) {
|
|
382
|
-
// Multiple data configuration
|
|
383
|
-
const dataValues = config.data
|
|
384
|
-
.map(data => item[data])
|
|
385
|
-
.filter(Boolean);
|
|
386
|
-
|
|
387
|
-
if (dataValues.length > 0) {
|
|
388
|
-
const combinedValue = dataValues.join(config.separator || ':');
|
|
389
|
-
const tag = [
|
|
390
|
-
config.prefix,
|
|
391
|
-
combinedValue,
|
|
392
|
-
config.suffix
|
|
393
|
-
].filter(Boolean).join('');
|
|
394
|
-
resolvedTags.push(tag);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
|
-
} else if (config.param) {
|
|
399
|
-
// Dynamic tags from params
|
|
400
|
-
if (typeof config.param === "string") {
|
|
401
|
-
// Single param configuration
|
|
402
|
-
const paramValue = params[config.param];
|
|
403
|
-
if (paramValue) {
|
|
404
|
-
const tag = [
|
|
405
|
-
config.prefix,
|
|
406
|
-
paramValue,
|
|
407
|
-
config.suffix
|
|
408
|
-
].filter(Boolean).join('');
|
|
409
|
-
resolvedTags.push(tag);
|
|
410
|
-
}
|
|
411
|
-
} else if (Array.isArray(config.param)) {
|
|
412
|
-
// Multiple params configuration
|
|
413
|
-
const paramValues = config.param
|
|
414
|
-
.map(param => params[param])
|
|
388
|
+
const tag = [
|
|
389
|
+
config.prefix,
|
|
390
|
+
config.value,
|
|
391
|
+
config.suffix
|
|
392
|
+
].filter(Boolean).join('');
|
|
393
|
+
return [tag];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Handle data-based tags
|
|
397
|
+
if (config.data && dataArray.length) {
|
|
398
|
+
return dataArray.flatMap(item => {
|
|
399
|
+
if (typeof item !== 'object') return [];
|
|
400
|
+
|
|
401
|
+
const dataFields = Array.isArray(config.data) ? config.data : [config.data];
|
|
402
|
+
const values = dataFields
|
|
403
|
+
.map(field => item[field])
|
|
415
404
|
.filter(Boolean);
|
|
416
405
|
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
406
|
+
if (!values.length) return [];
|
|
407
|
+
|
|
408
|
+
const value = values.join(config.separator || ':');
|
|
409
|
+
const tag = [
|
|
410
|
+
config.prefix,
|
|
411
|
+
value,
|
|
412
|
+
config.suffix
|
|
413
|
+
].filter(Boolean).join('');
|
|
414
|
+
return [tag];
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Handle param-based tags
|
|
419
|
+
if (config.param) {
|
|
420
|
+
const paramFields = Array.isArray(config.param) ? config.param : [config.param];
|
|
421
|
+
const values = paramFields
|
|
422
|
+
.map(field => params[field])
|
|
423
|
+
.filter(Boolean);
|
|
424
|
+
|
|
425
|
+
if (!values.length) return [];
|
|
426
|
+
|
|
427
|
+
const value = values.join(config.separator || ':');
|
|
428
|
+
const tag = [
|
|
429
|
+
config.prefix,
|
|
430
|
+
value,
|
|
431
|
+
config.suffix
|
|
432
|
+
].filter(Boolean).join('');
|
|
433
|
+
return [tag];
|
|
427
434
|
}
|
|
435
|
+
|
|
436
|
+
return [];
|
|
428
437
|
});
|
|
429
438
|
|
|
430
439
|
// Remove duplicates
|
|
@@ -451,13 +460,13 @@ class CachingService extends cds.Service {
|
|
|
451
460
|
case "Request":
|
|
452
461
|
case "NoaRequest":
|
|
453
462
|
|
|
454
|
-
return this.createCacheKey((!options.value && !options.template) ? { template: '{tenant}:{user}:{locale}:{hash}' } : options, {
|
|
455
|
-
req: keyOrObject,
|
|
456
|
-
params: keyOrObject.params,
|
|
457
|
-
data: keyOrObject.data,
|
|
458
|
-
locale: keyOrObject.locale,
|
|
459
|
-
user: keyOrObject.user.id,
|
|
460
|
-
tenant: keyOrObject.tenant
|
|
463
|
+
return this.createCacheKey((!options.value && !options.template) ? { template: '{tenant}:{user}:{locale}:{hash}' } : options, {
|
|
464
|
+
req: keyOrObject,
|
|
465
|
+
params: keyOrObject.params,
|
|
466
|
+
data: keyOrObject.data,
|
|
467
|
+
locale: keyOrObject.locale,
|
|
468
|
+
user: keyOrObject.user.id,
|
|
469
|
+
tenant: keyOrObject.tenant
|
|
461
470
|
});
|
|
462
471
|
case "cds.ql":
|
|
463
472
|
if (keyOrObject.SELECT) {
|