cds-caching 0.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/LICENSE +21 -0
- package/README.md +322 -0
- package/cds-plugin.js +7 -0
- package/index.cds +21 -0
- package/package.json +57 -0
- package/srv/CacheStatisticsHandler.js +213 -0
- package/srv/CachingService.js +528 -0
- package/srv/statistics-service.cds +37 -0
- package/srv/statistics-service.js +72 -0
- package/srv/util.js +83 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mike Zaschka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# Welcome to cds-caching
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This plugin for the [SAP Cloud Application Programming Model (CAP)](https://cap.cloud.sap/docs/) provides a robust caching service for CAP applications, addressing common performance challenges in distributed systems.
|
|
6
|
+
|
|
7
|
+
By reducing database requests and accelerating response times, caching is ideal for handling expensive operations like complex queries/calculations or external API calls. However, it introduces another layer of complexity and should be used with caution.
|
|
8
|
+
|
|
9
|
+
### Key Features
|
|
10
|
+
|
|
11
|
+
* **Flexible Key-Value Store** – Store and retrieve data using simple key-based access.
|
|
12
|
+
* **CachingService** – A cds.Service-based implementation with an intuitive API for seamless integration.
|
|
13
|
+
* **Event Handling** – Monitor and react to cache events, such as before/after storage and retrieval.
|
|
14
|
+
* **CAP-specific Caching** – Effortlessly cache CQN queries or CAP cds.Requests using code or the @cache annotation.
|
|
15
|
+
* **TTL Support** – Automatically manage data expiration with configurable time-to-live (TTL) settings.
|
|
16
|
+
* **Tag Support** – Use dynamic tags for flexible cache invalidation options.
|
|
17
|
+
* **Pluggable Storage Options** – Choose between in-memory caching or Redis.
|
|
18
|
+
* **Compression** – Compress cached data to save memory.
|
|
19
|
+
* **Integrated Statistics (WIP)** – Integrated statistics on cache hits, etc.
|
|
20
|
+
### Installation
|
|
21
|
+
|
|
22
|
+
Installing and using cds-caching is straightforward since it’s a CAP plugin. Simply run:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
npm install cds-caching
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Next, add a caching service configuration to your package.json. You can even define **multiple caching services**, which is recommended if you need to cache different types of data within your application.
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
"cds": {
|
|
34
|
+
"requires": {
|
|
35
|
+
"caching": {
|
|
36
|
+
"impl": "cds-caching",
|
|
37
|
+
"namespace": "my::app::caching"
|
|
38
|
+
},
|
|
39
|
+
"bp-caching": {
|
|
40
|
+
"impl": "cds-caching",
|
|
41
|
+
"namespace": "my::app::bp-caching"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
### Advanced Configuration
|
|
49
|
+
|
|
50
|
+
For more control, you can specify additional options. Some of those will be explained later:
|
|
51
|
+
|
|
52
|
+
```javascript
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
"cds": {
|
|
56
|
+
"requires": {
|
|
57
|
+
"caching": {
|
|
58
|
+
"impl": "cds-caching",
|
|
59
|
+
"namespace": "my::app::caching",
|
|
60
|
+
"store": "in-memory", // "in-memory" or "redis"
|
|
61
|
+
"compression": "lz4", // "lz4" or "gzip"
|
|
62
|
+
"credentials": { // if store is redis
|
|
63
|
+
"host": "localhost",
|
|
64
|
+
"port": 6379,
|
|
65
|
+
"password": "optional",
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
### Low level usage
|
|
74
|
+
|
|
75
|
+
The **cds-caching** plugin provides direct **key-value storage**, allowing fine-grained caching control. Its API follows a familiar pattern, making it easy to use if you have ever worked with other caching solutions and frameworks.
|
|
76
|
+
Here’s how you can interact with the cache:
|
|
77
|
+
|
|
78
|
+
```javascript
|
|
79
|
+
// Connect to the caching service
|
|
80
|
+
const cache = await cds.connect.to("caching")
|
|
81
|
+
|
|
82
|
+
// Store a value
|
|
83
|
+
await cache.set("key", "value")
|
|
84
|
+
|
|
85
|
+
// Retrieve the value
|
|
86
|
+
await cache.get("key") // => value
|
|
87
|
+
|
|
88
|
+
// Check if the key exists
|
|
89
|
+
await cache.has("key") // => true/false
|
|
90
|
+
|
|
91
|
+
// Delete the key
|
|
92
|
+
await cache.delete("key")
|
|
93
|
+
|
|
94
|
+
// Clear the whole cache
|
|
95
|
+
await cache.clear()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
This **low-level API** is useful when you need direct access to cached data, such as storing configuration values, or precomputed results. While the core API is simple, **cds-caching** also provides **higher-level caching strategies** that are more integrated with CAP. We will look at those in a minute. Before, let's focus on cache events.
|
|
99
|
+
#### Cache Events
|
|
100
|
+
|
|
101
|
+
The caching service follows the same event-driven principles as cds.Service instances. This means you can hook into events **before** and **after** storing or retrieving data. This is useful for logging, debugging, or performing additional actions when cache operations occur.
|
|
102
|
+
|
|
103
|
+
Each cache event (event.data.value) contains the following structure:
|
|
104
|
+
* **value** – The cached data.
|
|
105
|
+
* **tags** – Tags assigned to the cached entry.
|
|
106
|
+
* **timestamp** – The timestamp when the cache entry was created.
|
|
107
|
+
|
|
108
|
+
Here’s how you can listen for and react to cache events:
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
|
|
112
|
+
// Log before the cache is cleared
|
|
113
|
+
cache.before("CLEAR", () => {
|
|
114
|
+
console.log("Cache is about to be cleared")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Log before storing data
|
|
118
|
+
cache.before("SET", (event) => {
|
|
119
|
+
console.log(`Storing key: ${event.data.key} with value: ${event.data.value}`)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Log after retrieving data
|
|
123
|
+
cache.after("GET", (event) => {
|
|
124
|
+
console.log(`Retrieved key: ${event.data.key} with value: ${event.data.value}`)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This approach allows you to monitor cache activity and, if needed, manipulate data before storage or retrieval.
|
|
131
|
+
#### Invalidation via Time-To-Live (TTL)
|
|
132
|
+
|
|
133
|
+
**cds-caching** supports automatic cache invalidation via **TTL (Time-To-Live)**. Cached values will expire after the specified TTL, preventing stale data from lingering in memory.
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
// Store a value with a ttl
|
|
137
|
+
await cache.set("key", "value", { ttl: 6000 }) // 60 seconds
|
|
138
|
+
|
|
139
|
+
// Retrieve the value in time
|
|
140
|
+
await cache.get("key") // => value
|
|
141
|
+
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 6100)) // wait 61 seonds
|
|
143
|
+
|
|
144
|
+
// Now the value is not available anymore
|
|
145
|
+
await cache.get("key") // => undefined
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
TTL-based invalidation is useful for **temporary data, rate-limiting mechanisms, and frequently updated information**. However, **cds-caching** also supports other invalidation strategies, which will be covered in the advanced section.
|
|
149
|
+
#### Compression
|
|
150
|
+
|
|
151
|
+
To optimize storage, **cds-caching** supports **data compression**. This reduces cache size and can improve performance, especially when caching large datasets. Compression is **applied only when storing data**, meaning applications interact with uncompressed values. Available compression methods include:
|
|
152
|
+
* **lz4** – Faster compression and decompression, ideal for performance-critical applications.
|
|
153
|
+
* **gzip** – Higher compression ratio, reducing storage footprint at the cost of slightly increased CPU usage.
|
|
154
|
+
|
|
155
|
+
Compression can be configured via package.json:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"cds": {
|
|
160
|
+
"requires": {
|
|
161
|
+
"caching": {
|
|
162
|
+
"impl": "cds-caching",
|
|
163
|
+
"compression": "lz4"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Medium level usage
|
|
171
|
+
|
|
172
|
+
One of the core principles of **cds-caching** is **seamless integration with CAP**, aligning with CAP’s design and query execution model. As a result, **cds-caching** provides **native support** for **CQN (Core Query Notation) queries** and **cds.Requests**.
|
|
173
|
+
#### Caching CQN queries
|
|
174
|
+
|
|
175
|
+
CQN queries are widely used in CAP for **database operations** and **remote service calls (e.g., OData requests)**. Since these queries often involve **repeated data retrieval**, caching them can **significantly reduce response times** and **offload system resources**.
|
|
176
|
+
|
|
177
|
+
**cds-caching** treats **CQN queries as first-class objects**, allowing them to be passed directly into the caching API. Internally, it generates a unique key based on the CQN query structure, ensuring **consistent retrieval** and **cache integrity**.
|
|
178
|
+
|
|
179
|
+
**Example: Caching a CQN Query**
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
|
|
183
|
+
// Create the CQN object
|
|
184
|
+
const query = SELECT.from(Foo)
|
|
185
|
+
|
|
186
|
+
// Execute to fetch the result
|
|
187
|
+
const result = await cds.run(query) // => [{...}, {...}]
|
|
188
|
+
|
|
189
|
+
// Store value in the cache
|
|
190
|
+
await cache.set(query, result)
|
|
191
|
+
|
|
192
|
+
// Retrieve the value from the cache using the same CQN object
|
|
193
|
+
const cachedResult = await cache.get(query) // => [{...}, {...}]
|
|
194
|
+
|
|
195
|
+
// Create the key that is used internally
|
|
196
|
+
const key = cache.createKey(query)
|
|
197
|
+
|
|
198
|
+
// Delete the value from the cache
|
|
199
|
+
await cache.delete(query)
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
While **CQN queries** can be cached directly, **cds-caching** also supports **caching full** cds.Requests, including their **request context**.
|
|
204
|
+
#### Caching cds.Requests
|
|
205
|
+
|
|
206
|
+
Caching requests is particularly useful when exposing **remote services** through **local CAP services**. For example, if your CAP application proxies an **external API**, caching can significantly reduce redundant requests and improve response times.
|
|
207
|
+
|
|
208
|
+
👉 **Use Case:** [Exposing Remote Services](https://cap.cloud.sap/docs/guides/using-services#expose-remote-services)
|
|
209
|
+
|
|
210
|
+
**cds-caching** automatically includes contextual information in the cache key, making request caching **tenant- and user-aware**. The cache key incorporates:
|
|
211
|
+
|
|
212
|
+
* req.tenant – Ensures data is scoped per tenant in multi-tenant environments.
|
|
213
|
+
* req.user – Allows user-specific caching when necessary.
|
|
214
|
+
* req.locale – Supports localized responses when caching multilingual content.
|
|
215
|
+
|
|
216
|
+
**Example: Caching a cds.Request**
|
|
217
|
+
|
|
218
|
+
```javascript
|
|
219
|
+
this.on('READ', BusinessPartners, async (req, next) => {
|
|
220
|
+
const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
|
|
221
|
+
let value = await cache.get(req)
|
|
222
|
+
if(!value) {
|
|
223
|
+
value = await bupa.run(req)
|
|
224
|
+
await cache.set(req, next, { ttl: 3600 })
|
|
225
|
+
}
|
|
226
|
+
return value;
|
|
227
|
+
})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**When Not to Cache Full OData Services**
|
|
231
|
+
|
|
232
|
+
While caching individual **requests** can improve performance, **caching an entire OData service is generally not recommended**. Here’s why:
|
|
233
|
+
|
|
234
|
+
1. **Data Inconsistency** – OData services expose **live business data**, which frequently changes. Caching responses without an **appropriate invalidation strategy** can lead to outdated or incorrect data being served.
|
|
235
|
+
|
|
236
|
+
2. **Complex Query Variations** – OData allows **dynamic query parameters** ($filter, $expand, $orderby, etc.), making it difficult to cache efficiently without storing excessive variations.
|
|
237
|
+
|
|
238
|
+
3. **Large Payloads** – Full OData responses can be **significantly large**, consuming cache memory inefficiently compared to caching targeted **CQN queries** or specific **request results**.
|
|
239
|
+
|
|
240
|
+
👉 **Best Practice:** Instead of caching entire OData service responses, cache **specific queries or request results** where the data is frequently accessed and doesn’t change often.
|
|
241
|
+
|
|
242
|
+
For example, focussing on remote services, **static master data**, or **computed results** is much safer and more efficient than blindly caching full OData responses.
|
|
243
|
+
|
|
244
|
+
### Read-through CQN queries and cds.Requests
|
|
245
|
+
|
|
246
|
+
While this API is useful, it follows the **read-aside cache pattern**, meaning **manual cache checks** are required before fetching and storing data. In scenarios where caching logic becomes repetitive, **higher-level caching strategies** can help streamline this process and thanks to the already available CAP API, those are also available in cds-caching.
|
|
247
|
+
|
|
248
|
+
In **read-through caching**, queries and requests are executed **through the caching service itself**, reducing the need for manual cache handling. **cds-caching** extends CAP’s built-in service methods with two key functions:
|
|
249
|
+
|
|
250
|
+
* run – Executes **CQN queries** or **requests** against a database or remote OData service.
|
|
251
|
+
[CAP Documentation: srv.run(query)](https://cap.cloud.sap/docs/node.js/core-services#srv-run-query)
|
|
252
|
+
|
|
253
|
+
* send – Sends **custom synchronous requests** (e.g., to REST APIs) with configurable paths and headers.
|
|
254
|
+
[CAP Documentation: srv.send(request)](https://cap.cloud.sap/docs/node.js/core-services#srv-send-request)
|
|
255
|
+
|
|
256
|
+
Using **read-through caching**, the previous read-aside pattern can be reduced to a **single line of code**:
|
|
257
|
+
|
|
258
|
+
```javascript
|
|
259
|
+
this.on('READ', BusinessPartners, async (req, next) => {
|
|
260
|
+
const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
|
|
261
|
+
return cache.run(req, bupa, { ttl: 3600 })
|
|
262
|
+
})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
With this approach, **all requests to the external service will be automatically executed and cached**, eliminating the need for manual cache handling.
|
|
266
|
+
|
|
267
|
+
Some other examples :
|
|
268
|
+
|
|
269
|
+
```javascript
|
|
270
|
+
// Read-through for a CQN query
|
|
271
|
+
const queryResult = await cache.run(SELECT.from("Foo"), db, { ttl: 3600 })
|
|
272
|
+
|
|
273
|
+
// Read-through for a custom REST request
|
|
274
|
+
const restService = await cds.connect.to({
|
|
275
|
+
"kind": "rest",
|
|
276
|
+
"credentials": {
|
|
277
|
+
"url": "https://services.odata.org/V3/Northwind/Northwind.svc/"
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
const restResult = await cache.send({ method: "GET", path: "Products" }, restService, { ttl: 3600 });
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The **read-through strategy** makes caching **cleaner and more maintainable**, as it abstracts cache management entirely. However, **the first request is still slow** (since there’s no cached value yet), but all subsequent requests will be **served instantly from the cache**.
|
|
284
|
+
#### Wrapping async complex code
|
|
285
|
+
|
|
286
|
+
The **read-through approach** can also be applied to **non-CAP-specific** operations. **cds-caching** provides a wrap function that caches the result of **any asynchronous function**.
|
|
287
|
+
|
|
288
|
+
```javascript
|
|
289
|
+
const expensiveFunction = async (param) => { /* Do something complex */ }
|
|
290
|
+
|
|
291
|
+
// Wrap the function with caching
|
|
292
|
+
const cachedExpensiveFunction = await cache.wrap("key", expensiveFunction, { ttl: 3600 })
|
|
293
|
+
|
|
294
|
+
// First call executes the function
|
|
295
|
+
result = await cachedExpensiveFunction("someParam"); // No cache hit
|
|
296
|
+
|
|
297
|
+
// Subsequent calls retrieve the result from cache
|
|
298
|
+
result = await cachedExpensiveFunction("someParam"); // Cache hit
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
This is particularly useful for **heavy computations**, ensuring they only need to be executed **once per TTL period**.
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
## TODO:
|
|
305
|
+
|
|
306
|
+
- [ ] Add documentation for cache iteration
|
|
307
|
+
- [ ] Add documentation for cache invalidation
|
|
308
|
+
- [ ] Add documentation for cache annotations
|
|
309
|
+
- [ ] Add documentation for cache key generation
|
|
310
|
+
- [ ] Add documentation for cache tags
|
|
311
|
+
- [ ] Add documentation for cache statistics
|
|
312
|
+
- [ ] Add documentation for running locally with redis on docker
|
|
313
|
+
- [ ] Add documentation for running with redis on BTP
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
## Contributing
|
|
317
|
+
|
|
318
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
319
|
+
|
|
320
|
+
## License
|
|
321
|
+
|
|
322
|
+
This project is licensed under the MIT License - see the LICENSE file for detail
|
package/cds-plugin.js
ADDED
package/index.cds
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
namespace cds_caching;
|
|
2
|
+
|
|
3
|
+
entity Statistics {
|
|
4
|
+
key ID : String; // e.g., 'daily:2024-03-20' or 'hourly:2024-03-20-15'
|
|
5
|
+
key cache : String;
|
|
6
|
+
timestamp : DateTime;
|
|
7
|
+
period : String enum {
|
|
8
|
+
hourly;
|
|
9
|
+
daily;
|
|
10
|
+
monthly;
|
|
11
|
+
}; // Granularity
|
|
12
|
+
hits : Integer default 0;
|
|
13
|
+
misses : Integer default 0;
|
|
14
|
+
sets : Integer default 0;
|
|
15
|
+
deletes : Integer default 0;
|
|
16
|
+
errors : Integer default 0;
|
|
17
|
+
avgLatency : Double; // in milliseconds
|
|
18
|
+
p95Latency : Double; // 95th percentile latency
|
|
19
|
+
memoryUsage : Integer; // in bytes
|
|
20
|
+
itemCount : Integer;
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cds-caching",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A caching plugin for SAP CAP applications supporting Redis",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/mikezaschka/cds-caching.git"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"sap",
|
|
11
|
+
"cap",
|
|
12
|
+
"cds",
|
|
13
|
+
"caching",
|
|
14
|
+
"redis",
|
|
15
|
+
"keyv"
|
|
16
|
+
],
|
|
17
|
+
"author": "Mike Zaschka <opensource@mikezaschka.com>",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/mikezaschka/cds-caching/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/mikezaschka/cds-caching#readme",
|
|
23
|
+
"main": "cds-plugin.js",
|
|
24
|
+
"files": [
|
|
25
|
+
"srv/**",
|
|
26
|
+
"index.cds"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "jest --runInBand --silent",
|
|
30
|
+
"test:watch": "jest --watch",
|
|
31
|
+
"lint": "eslint ."
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@sap/cds": ">=8"
|
|
35
|
+
},
|
|
36
|
+
"workspaces": [
|
|
37
|
+
".",
|
|
38
|
+
"test/**"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@keyv/compress-gzip": "^2.0.2",
|
|
42
|
+
"@keyv/compress-lz4": "^1.0.0",
|
|
43
|
+
"@keyv/redis": "^4.2.0",
|
|
44
|
+
"keyv": "^5.2.3"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"chai": "^4.5.0",
|
|
48
|
+
"chai-as-promised": "^7.1.2",
|
|
49
|
+
"chai-subset": "^1.6.0",
|
|
50
|
+
"eslint": "^8.57.0",
|
|
51
|
+
"husky": "^9.0.11",
|
|
52
|
+
"jest": "^29.7.0"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=16"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
|
|
3
|
+
class CacheStatisticsHandler {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
console.log(options);
|
|
6
|
+
this.options = {
|
|
7
|
+
persistenceInterval: 5 * 60 * 60 * 1000, // 5 minutes
|
|
8
|
+
maxLatencies: 1000,
|
|
9
|
+
...options
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
this.stats = {
|
|
13
|
+
current: {
|
|
14
|
+
hits: 0,
|
|
15
|
+
misses: 0,
|
|
16
|
+
sets: 0,
|
|
17
|
+
deletes: 0,
|
|
18
|
+
errors: 0,
|
|
19
|
+
latencies: []
|
|
20
|
+
},
|
|
21
|
+
lastPersisted: Date.now()
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (this.options.enabled) {
|
|
25
|
+
cds.once('served', () => {
|
|
26
|
+
this.persistInterval = setInterval(
|
|
27
|
+
() => this.persistStats(),
|
|
28
|
+
this.options.persistenceInterval
|
|
29
|
+
);
|
|
30
|
+
})
|
|
31
|
+
cds.on('shutdown', () => {
|
|
32
|
+
clearInterval(this.persistInterval)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
recordHit(latencyMs) {
|
|
38
|
+
this.stats.current.hits++;
|
|
39
|
+
this.recordLatency(latencyMs);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
recordMiss() {
|
|
43
|
+
this.stats.current.misses++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
recordSet() {
|
|
47
|
+
this.stats.current.sets++;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
recordDelete() {
|
|
51
|
+
this.stats.current.deletes++;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
recordError() {
|
|
55
|
+
this.stats.current.errors++;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
recordLatency(ms) {
|
|
59
|
+
this.stats.current.latencies.push(ms);
|
|
60
|
+
if (this.stats.current.latencies.length > this.options.maxLatencies) {
|
|
61
|
+
this.stats.current.latencies.shift();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async persistStats() {
|
|
66
|
+
if (!this.options.enabled) return;
|
|
67
|
+
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
const hourlyId = `hourly:${now.slice(0, 13)}`;
|
|
70
|
+
const dailyId = `daily:${now.slice(0, 10)}`;
|
|
71
|
+
|
|
72
|
+
const stats = await this.calculateStats();
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
|
|
76
|
+
// Persist hourly stats
|
|
77
|
+
const existingHourly = await SELECT.one.from("cds_caching_Statistics")
|
|
78
|
+
.where({ ID: hourlyId, cache: this.options.cache });
|
|
79
|
+
|
|
80
|
+
if (!existingHourly) {
|
|
81
|
+
await INSERT.into('cds_caching_Statistics').entries([{
|
|
82
|
+
ID: hourlyId,
|
|
83
|
+
cache: this.options.cache,
|
|
84
|
+
timestamp: now,
|
|
85
|
+
period: 'hourly',
|
|
86
|
+
...stats
|
|
87
|
+
}]);
|
|
88
|
+
} else {
|
|
89
|
+
await UPDATE('cds_caching_Statistics')
|
|
90
|
+
.set({
|
|
91
|
+
hits: { '+=': stats.hits },
|
|
92
|
+
misses: { '+=': stats.misses },
|
|
93
|
+
sets: { '+=': stats.sets },
|
|
94
|
+
deletes: { '+=': stats.deletes },
|
|
95
|
+
errors: { '+=': stats.errors },
|
|
96
|
+
avgLatency: (existingHourly.avgLatency + stats.avgLatency) / 2,
|
|
97
|
+
p95Latency: Math.max(existingHourly.p95Latency, stats.p95Latency),
|
|
98
|
+
memoryUsage: stats.memoryUsage,
|
|
99
|
+
itemCount: stats.itemCount
|
|
100
|
+
})
|
|
101
|
+
.where({ ID: hourlyId, cache: this.options.cache });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Update or insert daily stats
|
|
105
|
+
const existingDaily = await SELECT.one.from("cds_caching_Statistics")
|
|
106
|
+
.where({ ID: dailyId, cache: this.options.cache });
|
|
107
|
+
|
|
108
|
+
if (existingDaily) {
|
|
109
|
+
await UPDATE('cds_caching_Statistics')
|
|
110
|
+
.set({
|
|
111
|
+
hits: { '+=': stats.hits },
|
|
112
|
+
misses: { '+=': stats.misses },
|
|
113
|
+
sets: { '+=': stats.sets },
|
|
114
|
+
deletes: { '+=': stats.deletes },
|
|
115
|
+
errors: { '+=': stats.errors },
|
|
116
|
+
avgLatency: (existingDaily.avgLatency + stats.avgLatency) / 2,
|
|
117
|
+
p95Latency: Math.max(existingDaily.p95Latency, stats.p95Latency),
|
|
118
|
+
memoryUsage: stats.memoryUsage,
|
|
119
|
+
itemCount: stats.itemCount
|
|
120
|
+
})
|
|
121
|
+
.where({ ID: dailyId, cache: this.options.cache });
|
|
122
|
+
} else {
|
|
123
|
+
await INSERT.into("cds_caching_Statistics").entries({
|
|
124
|
+
ID: dailyId,
|
|
125
|
+
cache: this.options.cache,
|
|
126
|
+
timestamp: now,
|
|
127
|
+
period: 'daily',
|
|
128
|
+
...stats
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.resetCurrentStats();
|
|
133
|
+
|
|
134
|
+
} catch (error) {
|
|
135
|
+
cds.log('caching').error('Error persisting cache statistics:', error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async calculateStats() {
|
|
140
|
+
const latencies = this.stats.current.latencies;
|
|
141
|
+
const avgLatency = latencies.length > 0
|
|
142
|
+
? latencies.reduce((a, b) => a + b, 0) / latencies.length
|
|
143
|
+
: 0;
|
|
144
|
+
const p95Latency = latencies.length > 0
|
|
145
|
+
? latencies.sort((a, b) => a - b)[Math.floor(latencies.length * 0.95)]
|
|
146
|
+
: 0;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
hits: this.stats.current.hits,
|
|
150
|
+
misses: this.stats.current.misses,
|
|
151
|
+
sets: this.stats.current.sets,
|
|
152
|
+
deletes: this.stats.current.deletes,
|
|
153
|
+
errors: this.stats.current.errors,
|
|
154
|
+
avgLatency,
|
|
155
|
+
p95Latency,
|
|
156
|
+
memoryUsage: process.memoryUsage().heapUsed,
|
|
157
|
+
itemCount: await this.options.getItemCount?.() || 0
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
resetCurrentStats() {
|
|
162
|
+
this.stats.current = {
|
|
163
|
+
hits: 0,
|
|
164
|
+
misses: 0,
|
|
165
|
+
sets: 0,
|
|
166
|
+
deletes: 0,
|
|
167
|
+
errors: 0,
|
|
168
|
+
latencies: []
|
|
169
|
+
};
|
|
170
|
+
this.stats.lastPersisted = Date.now();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async getStats(period = 'hourly', from, to) {
|
|
174
|
+
if (!this.options.enabled) return null;
|
|
175
|
+
|
|
176
|
+
const query = SELECT.from("cds_caching_Statistics")
|
|
177
|
+
.where({ period: period });
|
|
178
|
+
|
|
179
|
+
if (from) query.and({ timestamp: { '>=': from } });
|
|
180
|
+
if (to) query.and({ timestamp: { '<=': to } });
|
|
181
|
+
|
|
182
|
+
query.orderBy({ timestamp: 'desc' });
|
|
183
|
+
|
|
184
|
+
return await query;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getCurrentStats() {
|
|
188
|
+
if (!this.options.enabled) return null;
|
|
189
|
+
|
|
190
|
+
const { current, lastPersisted } = this.stats;
|
|
191
|
+
const latencies = current.latencies;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
...current,
|
|
195
|
+
avgLatency: latencies.length > 0
|
|
196
|
+
? latencies.reduce((a, b) => a + b, 0) / latencies.length
|
|
197
|
+
: 0,
|
|
198
|
+
p95Latency: latencies.length > 0
|
|
199
|
+
? latencies.sort((a, b) => a - b)[Math.floor(latencies.length * 0.95)]
|
|
200
|
+
: 0,
|
|
201
|
+
hitRatio: (current.hits / (current.hits + current.misses)) || 0,
|
|
202
|
+
lastPersisted: new Date(lastPersisted)
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
dispose() {
|
|
207
|
+
if (this.persistInterval) {
|
|
208
|
+
clearInterval(this.persistInterval);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = CacheStatisticsHandler;
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
const { Keyv } = require('keyv');
|
|
3
|
+
const { default: KeyvRedis } = require('@keyv/redis');
|
|
4
|
+
const { default: KeyvLz4 } = require('@keyv/compress-lz4');
|
|
5
|
+
const { default: KeyvGzip } = require('@keyv/compress-gzip');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const CacheStatisticsHandler = require('./CacheStatisticsHandler');
|
|
8
|
+
|
|
9
|
+
class CachingService extends cds.Service {
|
|
10
|
+
|
|
11
|
+
// Store annotated functions with metadata as requests do not contain a target
|
|
12
|
+
cacheAnnotatedFunctions = {
|
|
13
|
+
bound: [],
|
|
14
|
+
unbound: []
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
init() {
|
|
18
|
+
super.init()
|
|
19
|
+
this.LOG = cds.log('cds-caching')
|
|
20
|
+
this.options = this.options || {
|
|
21
|
+
store: null,
|
|
22
|
+
compression: null,
|
|
23
|
+
credentials: {
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let cacheOptions = {
|
|
28
|
+
namespace: this.options.namespace || this.name,
|
|
29
|
+
...(this.options.store === "redis" ? { store: new KeyvRedis({
|
|
30
|
+
...this.options.credentials,
|
|
31
|
+
// Redis, Hyperscaler Option on BTP provides a URI
|
|
32
|
+
...(this.options.credentials.uri ? { url: this.options.credentials.uri } : {}),
|
|
33
|
+
}) } : {}),
|
|
34
|
+
compression: this.options.compression === "lz4" ? new KeyvLz4() : this.options.compression === "gzip" ? new KeyvGzip() : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
|
|
48
|
+
this.cache = new Keyv(cacheOptions);
|
|
49
|
+
this.LOG._info && this.LOG.info(`Caching service initialized with namespace ${cacheOptions.namespace}`);
|
|
50
|
+
|
|
51
|
+
this.cache.on('error', err => {
|
|
52
|
+
this.LOG._error && this.LOG.error('Cache error', err);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
this.on('SET', async (event) => {
|
|
57
|
+
this.LOG._debug && this.LOG.debug(`SET ${event.data.key}`);
|
|
58
|
+
if (typeof event.data.value === "object") {
|
|
59
|
+
event.data.value = JSON.stringify(event.data.value);
|
|
60
|
+
}
|
|
61
|
+
await this.cache.set(event.data.key, event.data.value, (event.data.ttl || 0))
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.on('GET', async (event) => {
|
|
65
|
+
const value = await this.cache.get(event.data.key);
|
|
66
|
+
this.LOG._debug && this.LOG.debug(`GET ${event.data.key}`);
|
|
67
|
+
if (typeof value === "string") {
|
|
68
|
+
return JSON.parse(value);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.on('DELETE', async (event) => {
|
|
74
|
+
this.LOG._debug && this.LOG.debug(`DELETE ${event.data.key}`);
|
|
75
|
+
await this.cache.delete(event.data.key);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.on('CLEAR', async (event) => {
|
|
79
|
+
this.LOG._debug && this.LOG.debug(`CLEAR`);
|
|
80
|
+
await this.cache.clear();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Initialize statistics if enabled
|
|
84
|
+
if (this.options.statistics?.enabled) {
|
|
85
|
+
this.statistics = new CacheStatisticsHandler({
|
|
86
|
+
enabled: true,
|
|
87
|
+
...(this.options.statistics.persistenceInterval ? { persistenceInterval: this.options.statistics.persistenceInterval } : {}),
|
|
88
|
+
...(this.options.statistics.maxLatencies ? { maxLatencies: this.options.statistics.maxLatencies } : {}),
|
|
89
|
+
getItemCount: async () => {
|
|
90
|
+
let count = 0;
|
|
91
|
+
for await (const _ of this.iterator()) {
|
|
92
|
+
count++;
|
|
93
|
+
}
|
|
94
|
+
return count;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Enhance methods with statistics
|
|
99
|
+
this.after('GET', async (result, req) => {
|
|
100
|
+
const startTime = process.hrtime();
|
|
101
|
+
try {
|
|
102
|
+
if (result === undefined) {
|
|
103
|
+
this.statistics.recordMiss();
|
|
104
|
+
} else {
|
|
105
|
+
this.statistics.recordHit(this.getElapsedMs(startTime));
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
this.statistics.recordError();
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
this.after('SET', () => this.statistics.recordSet());
|
|
114
|
+
this.after('DELETE', () => this.statistics.recordDelete());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
addCachableFunction(name, options, isBound = false) {
|
|
119
|
+
|
|
120
|
+
this.cacheAnnotatedFunctions[isBound ? 'bound' : 'unbound'].push({ name, options });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getElapsedMs(startTime) {
|
|
124
|
+
const [seconds, nanoseconds] = process.hrtime(startTime);
|
|
125
|
+
return seconds * 1000 + nanoseconds / 1000000;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async getStats(period, from, to) {
|
|
129
|
+
return this.statistics?.getStats(period, from, to);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async getCurrentStats() {
|
|
133
|
+
return this.statistics?.getCurrentStats();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async dispose() {
|
|
137
|
+
this.statistics?.dispose();
|
|
138
|
+
await super.dispose();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Overloaded send method that caches the response of a remote service.
|
|
143
|
+
*
|
|
144
|
+
* @returns {Promise<any>} - the result
|
|
145
|
+
*/
|
|
146
|
+
async send() {
|
|
147
|
+
const arg1 = arguments[0];
|
|
148
|
+
const service = arguments[1];
|
|
149
|
+
const options = {
|
|
150
|
+
ttl: 0,
|
|
151
|
+
...(arguments[2] || {}),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (typeof arg1 !== "object" || !service.send || typeof options !== "object" || arg1.method !== "GET") {
|
|
155
|
+
return super.send(...arguments);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const key = this.createKey(arg1, options.key);
|
|
159
|
+
|
|
160
|
+
if (await this.has(key)) {
|
|
161
|
+
return this.get(key);
|
|
162
|
+
}
|
|
163
|
+
const response = await service.send(arg1);
|
|
164
|
+
await this.set(key, response, options);
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Function to extract the cache options from the request
|
|
169
|
+
extractFunctionCacheOptions(req, options) {
|
|
170
|
+
const functionType = req.query ? 'bound' : 'unbound';
|
|
171
|
+
const functionOptions = this.cacheAnnotatedFunctions[functionType].find(f => f.name === req.event);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
ttl: functionOptions?.['@cache.ttl'] || 0,
|
|
175
|
+
key: functionOptions?.['@cache.key'] || { template: '{tenant}-{user}-{locale}-{hash}' },
|
|
176
|
+
tags: functionOptions?.['@cache.tags'] || [],
|
|
177
|
+
...(options || {}),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
extractEntityCacheOptions(req, options) {
|
|
182
|
+
return {
|
|
183
|
+
ttl: req.target?.['@cache.ttl'] || 0,
|
|
184
|
+
key: req.target?.['@cache.key'] || { template: '{tenant}-{user}-{locale}-{hash}' },
|
|
185
|
+
tags: req.target?.['@cache.tags'] || [],
|
|
186
|
+
...(options || {}),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Overloaded run method that caches multiple things magically in the background
|
|
192
|
+
*
|
|
193
|
+
* @param {cds.ql} query - the query to run
|
|
194
|
+
* @param {Service} service - service instance to run the query on
|
|
195
|
+
* @param {object} options - additional options
|
|
196
|
+
*
|
|
197
|
+
*
|
|
198
|
+
* @param {Request} request - the request to run
|
|
199
|
+
* @param {next} function - the next fuction
|
|
200
|
+
* @param {object} options - additional options
|
|
201
|
+
|
|
202
|
+
* @returns {Promise<any>} - the result
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
async run() {
|
|
206
|
+
const arg1 = arguments[0];
|
|
207
|
+
if (typeof arg1 === "object") {
|
|
208
|
+
switch (arg1.constructor.name) {
|
|
209
|
+
case "Request":
|
|
210
|
+
case "NoaRequest":
|
|
211
|
+
const req = arg1;
|
|
212
|
+
const next = arguments[1];
|
|
213
|
+
|
|
214
|
+
if (req.query?.UPDATE || req.query?.INSERT || req.query?.DELETE) {
|
|
215
|
+
return next();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
|
|
219
|
+
req.cacheKey = this.createKey(req, req.cacheOptions.key);
|
|
220
|
+
req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
|
|
221
|
+
if (await this.has(req.cacheKey)) {
|
|
222
|
+
return this.get(req.cacheKey);
|
|
223
|
+
}
|
|
224
|
+
const response = await next();
|
|
225
|
+
req.cacheOptions.tags = this.resolveTags(req.cacheOptions.tags, response, req.params);
|
|
226
|
+
await this.set(req.cacheKey, response, req.cacheOptions);
|
|
227
|
+
return response;
|
|
228
|
+
case "cds.ql":
|
|
229
|
+
const query = arg1;
|
|
230
|
+
const srv = arguments[1];
|
|
231
|
+
|
|
232
|
+
if (query.SELECT) {
|
|
233
|
+
|
|
234
|
+
let options = {
|
|
235
|
+
ttl: 0,
|
|
236
|
+
tags: [],
|
|
237
|
+
key: { template: '{hash}' },
|
|
238
|
+
...(arguments[2] || {}),
|
|
239
|
+
};
|
|
240
|
+
query.cacheKey = this.createKey(query, options.key);
|
|
241
|
+
if (await this.has(query.cacheKey)) {
|
|
242
|
+
return this.get(query.cacheKey);
|
|
243
|
+
}
|
|
244
|
+
const data = await srv.run(query);
|
|
245
|
+
options.tags = this.resolveTags(options.tags, data, query.params);
|
|
246
|
+
await this.set(query.cacheKey, data, options);
|
|
247
|
+
return data;
|
|
248
|
+
} else {
|
|
249
|
+
return srv.run(query);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return super.run(arg1);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Wraps an async function and caches the result
|
|
259
|
+
*
|
|
260
|
+
* @param {string} key - the key to cache
|
|
261
|
+
* @param {function} asyncFunction - the async function to cache
|
|
262
|
+
* @param {object} options - additional options
|
|
263
|
+
* @returns {function} - the wrapped function
|
|
264
|
+
*/
|
|
265
|
+
wrap(key, asyncFunction, options = {}) {
|
|
266
|
+
const cacheKey = this.createKey(key, options.key);
|
|
267
|
+
return async (...args) => {
|
|
268
|
+
if (await this.has(cacheKey)) {
|
|
269
|
+
return this.get(cacheKey);
|
|
270
|
+
}
|
|
271
|
+
const response = await asyncFunction(...args);
|
|
272
|
+
await this.set(cacheKey, response, options);
|
|
273
|
+
return response;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async set(key, value, options = {}) {
|
|
278
|
+
const wrappedValue = {
|
|
279
|
+
value,
|
|
280
|
+
tags: options.tags || [],
|
|
281
|
+
timestamp: Date.now()
|
|
282
|
+
};
|
|
283
|
+
await this.send('SET', {
|
|
284
|
+
key: this.createKey(key, options.key),
|
|
285
|
+
value: wrappedValue,
|
|
286
|
+
ttl: options.ttl || 0
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async get(key) {
|
|
291
|
+
const wrappedValue = await this.send('GET', { key: this.createKey(key) });
|
|
292
|
+
return wrappedValue?.value;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async has(key) {
|
|
296
|
+
return this.cache.has(this.createKey(key));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async delete(key) {
|
|
300
|
+
await this.send('DELETE', { key: this.createKey(key) });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async clear() {
|
|
304
|
+
await this.send('CLEAR');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async deleteByTag(tag) {
|
|
309
|
+
for await (const [key, wrappedValue] of this.iterator()) {
|
|
310
|
+
if (wrappedValue?.tags?.includes(tag)) {
|
|
311
|
+
await this.delete(key);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Metadata
|
|
317
|
+
async metadata(key) {
|
|
318
|
+
const wrappedValue = await this.send('GET', { key: this.createKey(key) });
|
|
319
|
+
if (!wrappedValue) return null;
|
|
320
|
+
|
|
321
|
+
const { value, ...metadata } = wrappedValue;
|
|
322
|
+
return metadata;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async tags(key) {
|
|
326
|
+
const wrappedValue = await this.send('GET', { key: this.createKey(key) });
|
|
327
|
+
return wrappedValue?.tags || [];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Iterators
|
|
331
|
+
async *iterator() {
|
|
332
|
+
for await (const [key, value] of this.cache.iterator()) {
|
|
333
|
+
if (typeof value === "string") {
|
|
334
|
+
yield [key, JSON.parse(value)];
|
|
335
|
+
} else {
|
|
336
|
+
yield [key, value];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Resolves tags from tag configurations and data
|
|
343
|
+
* @param {Array} tagConfigs - Array of tag configuration objects
|
|
344
|
+
* @param {Object|Array} data - Data object(s) to extract data values from
|
|
345
|
+
* @param {Object} params - Parameters object to extract values from
|
|
346
|
+
* @returns {string[]} Array of resolved tags
|
|
347
|
+
*/
|
|
348
|
+
resolveTags(tagConfigs = [], data, params = {}) {
|
|
349
|
+
let resolvedTags = [];
|
|
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
|
+
}
|
|
358
|
+
|
|
359
|
+
// Convert data to array if single object
|
|
360
|
+
const dataArray = data ? (Array.isArray(data) ? data : [data]) : [];
|
|
361
|
+
|
|
362
|
+
// Process each tag configuration
|
|
363
|
+
tagConfigs.forEach(config => {
|
|
364
|
+
if (config.value) {
|
|
365
|
+
// Static tag
|
|
366
|
+
resolvedTags.push(config.value);
|
|
367
|
+
} else if (config.data) {
|
|
368
|
+
// Dynamic tags from data
|
|
369
|
+
dataArray.forEach(item => {
|
|
370
|
+
if (typeof config.data === "string") {
|
|
371
|
+
// Single data configuration
|
|
372
|
+
const dataValue = item[config.data];
|
|
373
|
+
if (dataValue) {
|
|
374
|
+
const tag = [
|
|
375
|
+
config.prefix,
|
|
376
|
+
dataValue,
|
|
377
|
+
config.suffix
|
|
378
|
+
].filter(Boolean).join('');
|
|
379
|
+
resolvedTags.push(tag);
|
|
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])
|
|
415
|
+
.filter(Boolean);
|
|
416
|
+
|
|
417
|
+
if (paramValues.length > 0) {
|
|
418
|
+
const combinedValue = paramValues.join(config.separator || ':');
|
|
419
|
+
const tag = [
|
|
420
|
+
config.prefix,
|
|
421
|
+
combinedValue,
|
|
422
|
+
config.suffix
|
|
423
|
+
].filter(Boolean).join('');
|
|
424
|
+
resolvedTags.push(tag);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Remove duplicates
|
|
431
|
+
return [...new Set(resolvedTags)];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Basic cache operations
|
|
435
|
+
createKey(keyOrObject, options = {}) {
|
|
436
|
+
|
|
437
|
+
// If the key is a string, use it
|
|
438
|
+
if (typeof keyOrObject === "string") {
|
|
439
|
+
return keyOrObject;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Otherwise, create a key based on the object
|
|
443
|
+
if (typeof keyOrObject === "object") {
|
|
444
|
+
|
|
445
|
+
// If the key is provided in the options, use it
|
|
446
|
+
if (keyOrObject.cacheKey) {
|
|
447
|
+
return keyOrObject.cacheKey;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
switch (keyOrObject.constructor.name) {
|
|
451
|
+
case "Request":
|
|
452
|
+
case "NoaRequest":
|
|
453
|
+
|
|
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
|
|
461
|
+
});
|
|
462
|
+
case "cds.ql":
|
|
463
|
+
if (keyOrObject.SELECT) {
|
|
464
|
+
return this.createCacheKey((!options.value && !options.template) ? { template: '{hash}' } : options, { query: keyOrObject });
|
|
465
|
+
} else {
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
default:
|
|
469
|
+
return this.createCacheKey((!options.value && !options.template) ? { template: '{hash}' } : options, { data: keyOrObject });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Creates a cache key based on configuration and context
|
|
476
|
+
* @param {Object} keyConfig - Key configuration object
|
|
477
|
+
* @param {Object} context - Context containing data, params, and request info
|
|
478
|
+
* @returns {string} Generated cache key
|
|
479
|
+
*/
|
|
480
|
+
createCacheKey(keyConfig = {}, context = {}) {
|
|
481
|
+
const { data, params, req, query, locale, user, tenant } = context;
|
|
482
|
+
|
|
483
|
+
// If a static key value is provided, use it
|
|
484
|
+
if (keyConfig.value) {
|
|
485
|
+
return keyConfig.value;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let keyValue = '';
|
|
489
|
+
|
|
490
|
+
const hashParts = [
|
|
491
|
+
...(data ? [data] : []),
|
|
492
|
+
...(params ? [params] : []),
|
|
493
|
+
...(query ? [query] : [])
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
const createHash = (data) => crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
|
|
497
|
+
|
|
498
|
+
// Handle template with placeholders
|
|
499
|
+
if (keyConfig.template) {
|
|
500
|
+
const contextVars = {
|
|
501
|
+
tenant: tenant || 'global',
|
|
502
|
+
user: user || 'anonymous',
|
|
503
|
+
locale: locale || 'en',
|
|
504
|
+
hash: createHash(hashParts)
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
keyValue = keyConfig.template.replace(
|
|
508
|
+
/\{(tenant|user|locale|hash)\}/g,
|
|
509
|
+
(match, variable) => contextVars[variable]
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// If no key value generated, create hash from input
|
|
514
|
+
if (!keyValue) {
|
|
515
|
+
keyValue = createHash(hashParts);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Combine with prefix/suffix
|
|
519
|
+
return [
|
|
520
|
+
keyConfig.prefix,
|
|
521
|
+
keyValue,
|
|
522
|
+
keyConfig.suffix
|
|
523
|
+
].filter(Boolean).join('');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
module.exports = CachingService;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
using {cds_caching as stats} from '../index.cds';
|
|
2
|
+
|
|
3
|
+
@path : 'cache-stats'
|
|
4
|
+
//@requires: 'cache-admin'
|
|
5
|
+
service StatisticsService {
|
|
6
|
+
@readonly
|
|
7
|
+
entity Statistics as projection on stats.Statistics;
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
@readonly
|
|
11
|
+
@cds.persistence.skip
|
|
12
|
+
entity CurrentStats {
|
|
13
|
+
key ID : String default 'current';
|
|
14
|
+
key cache : String;
|
|
15
|
+
hits : Integer default 0;
|
|
16
|
+
misses : Integer default 0;
|
|
17
|
+
sets : Integer default 0;
|
|
18
|
+
deletes : Integer default 0;
|
|
19
|
+
errors : Integer default 0;
|
|
20
|
+
latencies : array of Double; // Rolling window of recent latencies
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Action to get stats for a specific time range
|
|
24
|
+
function getStats(period : String enum {
|
|
25
|
+
hourly;
|
|
26
|
+
daily;
|
|
27
|
+
monthly;
|
|
28
|
+
},
|
|
29
|
+
from : DateTime,
|
|
30
|
+
to : DateTime) returns array of Statistics;
|
|
31
|
+
|
|
32
|
+
// Action to get current statistics
|
|
33
|
+
function getCurrentStats() returns CurrentStats;
|
|
34
|
+
// Action to manually trigger stats persistence
|
|
35
|
+
action persistStats() returns Boolean;
|
|
36
|
+
*/
|
|
37
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const cds = require('@sap/cds')
|
|
2
|
+
|
|
3
|
+
class StatisticsService extends cds.ApplicationService {
|
|
4
|
+
async init() {
|
|
5
|
+
const { Statistics, Current } = this.entities
|
|
6
|
+
|
|
7
|
+
// Get reference to cache service
|
|
8
|
+
const cache = await cds.connect.to('caching')
|
|
9
|
+
|
|
10
|
+
// Handle getStats function
|
|
11
|
+
this.on('getStats', async (req) => {
|
|
12
|
+
const { period, from, to } = req.data
|
|
13
|
+
return cache.getStats(period, from, to)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// Handle getCurrentStats function
|
|
17
|
+
this.on('getCurrentStats', async () => {
|
|
18
|
+
const stats = await cache.getCurrentStats()
|
|
19
|
+
if (!stats) return null
|
|
20
|
+
|
|
21
|
+
// Convert to CurrentStats entity format
|
|
22
|
+
return {
|
|
23
|
+
id: 'current',
|
|
24
|
+
hits: stats.hits,
|
|
25
|
+
misses: stats.misses,
|
|
26
|
+
sets: stats.sets,
|
|
27
|
+
deletes: stats.deletes,
|
|
28
|
+
errors: stats.errors,
|
|
29
|
+
latencies: stats.latencies
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Handle persistStats action
|
|
34
|
+
this.on('persistStats', async (req) => {
|
|
35
|
+
if (!cache.statistics?.persistStats) {
|
|
36
|
+
req.warn('Statistics are not enabled')
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await cache.statistics.persistStats()
|
|
41
|
+
return true
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Add custom handlers for the entities if needed
|
|
45
|
+
this.before('READ', 'Statistics', req => {
|
|
46
|
+
if (!cache.statistics) {
|
|
47
|
+
req.warn('Statistics are not enabled')
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
this.on('READ', 'CurrentStats', async req => {
|
|
53
|
+
const stats = await cache.getCurrentStats()
|
|
54
|
+
if (!stats) return []
|
|
55
|
+
|
|
56
|
+
// Convert to CurrentStats entity format
|
|
57
|
+
return {
|
|
58
|
+
id: 'current',
|
|
59
|
+
hits: stats.hits,
|
|
60
|
+
misses: stats.misses,
|
|
61
|
+
sets: stats.sets,
|
|
62
|
+
deletes: stats.deletes,
|
|
63
|
+
errors: stats.errors,
|
|
64
|
+
latencies: stats.latencies
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await super.init()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = StatisticsService
|
package/srv/util.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const cds = require("@sap/cds")
|
|
2
|
+
|
|
3
|
+
const bindFunction = async (service, action, isBound = false) => {
|
|
4
|
+
const cache = await cds.connect.to(action['@cache.service'] || "caching");
|
|
5
|
+
cache.addCachableFunction(action.name.split('.').pop(), action, isBound);
|
|
6
|
+
|
|
7
|
+
service.prepend(function () {
|
|
8
|
+
service.on(action.name.split('.').pop(), async (req, next) => {
|
|
9
|
+
const cache = await cds.connect.to(action['@cache.service'] || "caching");
|
|
10
|
+
const data = await cache.run(req, next, {
|
|
11
|
+
ttl: action['@cache.ttl'],
|
|
12
|
+
tags: action['@cache.tags'],
|
|
13
|
+
key: action['@cache.key']
|
|
14
|
+
});
|
|
15
|
+
return data;
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const bindEntity = async (service, entity) => {
|
|
21
|
+
service.prepend(function () {
|
|
22
|
+
service.on('READ', entity.name, async (req, next) => {
|
|
23
|
+
const cache = await cds.connect.to(entity['@cache.service'] || "caching");
|
|
24
|
+
const data = await cache.run(req, next, {
|
|
25
|
+
ttl: entity['@cache.ttl'],
|
|
26
|
+
tags: entity['@cache.tags'],
|
|
27
|
+
key: entity['@cache.key']
|
|
28
|
+
});
|
|
29
|
+
return data;
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const scanCachingAnnotations = async (srvs) => {
|
|
35
|
+
LOG = cds.log('cds-caching')
|
|
36
|
+
|
|
37
|
+
// Grep all app services
|
|
38
|
+
const services = [];
|
|
39
|
+
for (const [name, config] of Object.entries(srvs)) {
|
|
40
|
+
const service = await cds.connect.to(name);
|
|
41
|
+
|
|
42
|
+
if (service.definition?.kind === 'service') {
|
|
43
|
+
services.push(service);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Grep all external services
|
|
48
|
+
for (const [name, config] of Object.entries(cds.env.requires)) {
|
|
49
|
+
if (config.kind === 'odata-v2' || config.kind === 'odata' || config.kind === 'rest') {
|
|
50
|
+
const service = await cds.connect.to(name);
|
|
51
|
+
services.push(service);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const service of services) {
|
|
56
|
+
|
|
57
|
+
// functions
|
|
58
|
+
for (const [name, action] of Object.entries(service.actions)) {
|
|
59
|
+
if (Object.keys(action).some(key => key.startsWith('@cache')) && action.kind === 'function') {
|
|
60
|
+
LOG._debug && LOG.debug(`Caching enabled for function ${action.name}`);
|
|
61
|
+
await bindFunction(service, action);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// entities
|
|
66
|
+
for (const entity of service.entities) {
|
|
67
|
+
if (Object.keys(entity).some(key => key.startsWith('@cache'))) {
|
|
68
|
+
await bindEntity(service, entity);
|
|
69
|
+
LOG._debug && LOG.debug(`Caching enabled for entity ${entity.name}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// bound functions
|
|
73
|
+
for (const [name, action] of Object.entries(entity.actions || {})) {
|
|
74
|
+
if (Object.keys(action).some(key => key.startsWith('@cache')) && action.kind === 'function') {
|
|
75
|
+
bindFunction(service, action, true);
|
|
76
|
+
LOG._debug && LOG.debug(`Caching enabled for bound function ${action.name}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { scanCachingAnnotations }
|