cds-caching 0.3.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +691 -278
- package/cds-plugin.js +2 -2
- package/index.cds +165 -20
- package/lib/CachingService.d.ts +328 -0
- package/lib/CachingService.js +373 -0
- package/lib/operations/AsyncOperations.js +335 -0
- package/lib/operations/BasicOperations.js +211 -0
- package/lib/operations/CapOperations.js +540 -0
- package/lib/support/CacheStatisticsHandler.js +1124 -0
- package/lib/support/CacheStoreManager.js +120 -0
- package/lib/support/KeyManager.js +132 -0
- package/lib/support/RuntimeConfigurationManager.js +148 -0
- package/lib/support/StatisticsPersistenceManager.js +375 -0
- package/lib/support/TagResolver.js +125 -0
- package/lib/util.js +201 -0
- package/package.json +18 -13
- package/srv/caching-api-service.js +124 -0
- package/srv/CacheStatisticsHandler.js +0 -213
- package/srv/CachingService.js +0 -587
- package/srv/statistics-service.cds +0 -37
- package/srv/statistics-service.js +0 -72
- package/srv/util.js +0 -101
package/srv/CachingService.js
DELETED
|
@@ -1,587 +0,0 @@
|
|
|
1
|
-
const cds = require('@sap/cds');
|
|
2
|
-
const { Keyv } = require('keyv');
|
|
3
|
-
const { default: KeyvRedis } = require('@keyv/redis');
|
|
4
|
-
const { default: KeyvSqlite } = require('@keyv/sqlite');
|
|
5
|
-
const { default: KeyvLz4 } = require('@keyv/compress-lz4');
|
|
6
|
-
const { default: KeyvGzip } = require('@keyv/compress-gzip');
|
|
7
|
-
const crypto = require('crypto');
|
|
8
|
-
const CacheStatisticsHandler = require('./CacheStatisticsHandler');
|
|
9
|
-
|
|
10
|
-
class CachingService extends cds.Service {
|
|
11
|
-
|
|
12
|
-
// Store annotated functions with metadata as requests do not contain a target
|
|
13
|
-
cacheAnnotatedFunctions = {
|
|
14
|
-
bound: [],
|
|
15
|
-
unbound: []
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
init() {
|
|
19
|
-
super.init()
|
|
20
|
-
this.LOG = cds.log('cds-caching')
|
|
21
|
-
this.options = this.options || {
|
|
22
|
-
store: null,
|
|
23
|
-
compression: null,
|
|
24
|
-
credentials: {}
|
|
25
|
-
};
|
|
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
|
-
|
|
57
|
-
let cacheOptions = {
|
|
58
|
-
namespace: this.options.namespace || this.name,
|
|
59
|
-
store: store,
|
|
60
|
-
compression: this.options.compression === "lz4" ? new KeyvLz4() : this.options.compression === "gzip" ? new KeyvGzip() : undefined
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
this.cache = new Keyv(cacheOptions);
|
|
65
|
-
this.LOG._info && this.LOG.info(`Caching service initialized with namespace ${cacheOptions.namespace}`);
|
|
66
|
-
|
|
67
|
-
this.cache.on('error', err => {
|
|
68
|
-
this.LOG._error && this.LOG.error('Cache error', err);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
this.on('SET', async (event) => {
|
|
73
|
-
this.LOG._debug && this.LOG.debug(`SET ${event.data.key}`);
|
|
74
|
-
if (typeof event.data.value === "object") {
|
|
75
|
-
event.data.value = JSON.stringify(event.data.value);
|
|
76
|
-
}
|
|
77
|
-
await this.cache.set(event.data.key, event.data.value, (event.data.ttl || 0))
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
this.on('GET', async (event) => {
|
|
81
|
-
const value = await this.cache.get(event.data.key);
|
|
82
|
-
this.LOG._debug && this.LOG.debug(`GET ${event.data.key}`);
|
|
83
|
-
if (typeof value === "string") {
|
|
84
|
-
return JSON.parse(value);
|
|
85
|
-
}
|
|
86
|
-
return value;
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
this.on('DELETE', async (event) => {
|
|
90
|
-
this.LOG._debug && this.LOG.debug(`DELETE ${event.data.key}`);
|
|
91
|
-
await this.cache.delete(event.data.key);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
this.on('CLEAR', async (event) => {
|
|
95
|
-
this.LOG._debug && this.LOG.debug(`CLEAR`);
|
|
96
|
-
await this.cache.clear();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// Initialize statistics if enabled
|
|
100
|
-
if (this.options.statistics?.enabled) {
|
|
101
|
-
this.statistics = new CacheStatisticsHandler({
|
|
102
|
-
enabled: true,
|
|
103
|
-
...(this.options.statistics.persistenceInterval ? { persistenceInterval: this.options.statistics.persistenceInterval } : {}),
|
|
104
|
-
...(this.options.statistics.maxLatencies ? { maxLatencies: this.options.statistics.maxLatencies } : {}),
|
|
105
|
-
getItemCount: async () => {
|
|
106
|
-
let count = 0;
|
|
107
|
-
for await (const _ of this.iterator()) {
|
|
108
|
-
count++;
|
|
109
|
-
}
|
|
110
|
-
return count;
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Enhance methods with statistics
|
|
115
|
-
this.after('GET', async (result, req) => {
|
|
116
|
-
const startTime = process.hrtime();
|
|
117
|
-
try {
|
|
118
|
-
if (result === undefined) {
|
|
119
|
-
this.statistics.recordMiss();
|
|
120
|
-
} else {
|
|
121
|
-
this.statistics.recordHit(this.getElapsedMs(startTime));
|
|
122
|
-
}
|
|
123
|
-
} catch (error) {
|
|
124
|
-
this.statistics.recordError();
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
this.after('SET', () => this.statistics.recordSet());
|
|
130
|
-
this.after('DELETE', () => this.statistics.recordDelete());
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
addCachableFunction(name, options, isBound = false) {
|
|
135
|
-
|
|
136
|
-
this.cacheAnnotatedFunctions[isBound ? 'bound' : 'unbound'].push({ name, options });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
getElapsedMs(startTime) {
|
|
140
|
-
const [seconds, nanoseconds] = process.hrtime(startTime);
|
|
141
|
-
return seconds * 1000 + nanoseconds / 1000000;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async getStats(period, from, to) {
|
|
145
|
-
return this.statistics?.getStats(period, from, to);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async getCurrentStats() {
|
|
149
|
-
return this.statistics?.getCurrentStats();
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async dispose() {
|
|
153
|
-
this.statistics?.dispose();
|
|
154
|
-
await super.dispose();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Overloaded send method that caches the response of a remote service.
|
|
159
|
-
*
|
|
160
|
-
* @returns {Promise<any>} - the result
|
|
161
|
-
*/
|
|
162
|
-
async send() {
|
|
163
|
-
const arg1 = arguments[0];
|
|
164
|
-
const service = arguments[1];
|
|
165
|
-
const options = {
|
|
166
|
-
ttl: 0,
|
|
167
|
-
...(arguments[2] || {}),
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (typeof arg1 !== "object" || !service.send || typeof options !== "object" || arg1.method !== "GET") {
|
|
171
|
-
return super.send(...arguments);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const key = this.createKey(arg1, options.key);
|
|
175
|
-
|
|
176
|
-
if (await this.has(key)) {
|
|
177
|
-
return this.get(key);
|
|
178
|
-
}
|
|
179
|
-
const response = await service.send(arg1);
|
|
180
|
-
await this.set(key, response, options);
|
|
181
|
-
return response;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Function to extract the cache options from the request
|
|
185
|
-
extractFunctionCacheOptions(req, options) {
|
|
186
|
-
const functionType = req.query ? 'bound' : 'unbound';
|
|
187
|
-
const functionOptions = this.cacheAnnotatedFunctions[functionType].find(f => f.name === req.event);
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
ttl: functionOptions?.['@cache.ttl'] || 0,
|
|
191
|
-
key: functionOptions?.['@cache.key'] || { template: '{tenant}-{user}-{locale}-{hash}' },
|
|
192
|
-
tags: functionOptions?.['@cache.tags'] || [],
|
|
193
|
-
...(options || {}),
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
extractEntityCacheOptions(req, options) {
|
|
198
|
-
return {
|
|
199
|
-
ttl: req.target?.['@cache.ttl'] || 0,
|
|
200
|
-
key: req.target?.['@cache.key'] || { template: '{tenant}-{user}-{locale}-{hash}' },
|
|
201
|
-
tags: req.target?.['@cache.tags'] || [],
|
|
202
|
-
...(options || {}),
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Overloaded run method that caches multiple things magically in the background
|
|
208
|
-
*
|
|
209
|
-
* @param {cds.ql} query - the query to run
|
|
210
|
-
* @param {Service} service - service instance to run the query on
|
|
211
|
-
* @param {object} options - additional options
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
* @param {Request} request - the request to run
|
|
215
|
-
* @param {next} function - the next fuction
|
|
216
|
-
* @param {object} options - additional options
|
|
217
|
-
|
|
218
|
-
* @returns {Promise<any>} - the result
|
|
219
|
-
*/
|
|
220
|
-
|
|
221
|
-
async run() {
|
|
222
|
-
const arg1 = arguments[0];
|
|
223
|
-
if (typeof arg1 === "object") {
|
|
224
|
-
switch (arg1.constructor.name) {
|
|
225
|
-
case "Request":
|
|
226
|
-
case "NoaRequest":
|
|
227
|
-
case "ODataRequest":
|
|
228
|
-
const req = arg1;
|
|
229
|
-
const next = arguments[1];
|
|
230
|
-
|
|
231
|
-
if (req.query?.UPDATE || req.query?.INSERT || req.query?.DELETE) {
|
|
232
|
-
return next();
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
|
|
236
|
-
req.cacheKey = this.createKey(req, req.cacheOptions.key);
|
|
237
|
-
req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
|
|
238
|
-
const cachedValue = await this.get(req.cacheKey);
|
|
239
|
-
if (cachedValue) {
|
|
240
|
-
return cachedValue;
|
|
241
|
-
}
|
|
242
|
-
const response = await next();
|
|
243
|
-
req.cacheOptions.tags = this.resolveTags(req.cacheOptions.tags, response, { ...req.params, user: req.user.id, tenant: req.tenant, locale: req.locale, hash: this.createKey(req, { template: '{hash}' }) });
|
|
244
|
-
await this.set(req.cacheKey, response, req.cacheOptions);
|
|
245
|
-
return response;
|
|
246
|
-
case "cds.ql":
|
|
247
|
-
const query = arg1;
|
|
248
|
-
const srv = arguments[1];
|
|
249
|
-
|
|
250
|
-
if (query.SELECT) {
|
|
251
|
-
|
|
252
|
-
let options = {
|
|
253
|
-
ttl: 0,
|
|
254
|
-
tags: [],
|
|
255
|
-
key: { template: '{hash}' },
|
|
256
|
-
...(arguments[2] || {}),
|
|
257
|
-
};
|
|
258
|
-
query.cacheKey = this.createKey(query, options.key);
|
|
259
|
-
if (await this.has(query.cacheKey)) {
|
|
260
|
-
return this.get(query.cacheKey);
|
|
261
|
-
}
|
|
262
|
-
const data = await srv.run(query);
|
|
263
|
-
options.tags = this.resolveTags(options.tags, data, { ...query.params, hash: this.createKey(query, { template: '{hash}' }) });
|
|
264
|
-
await this.set(query.cacheKey, data, options);
|
|
265
|
-
return data;
|
|
266
|
-
} else {
|
|
267
|
-
return srv.run(query);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return super.run(arg1);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Wraps an async function and caches the result
|
|
277
|
-
*
|
|
278
|
-
* @param {string} key - the key to cache
|
|
279
|
-
* @param {function} asyncFunction - the async function to cache
|
|
280
|
-
* @param {object} options - additional options
|
|
281
|
-
* @returns {function} - the wrapped function
|
|
282
|
-
*/
|
|
283
|
-
wrap(key, asyncFunction, options = {}) {
|
|
284
|
-
const cacheKey = this.createKey(key, options.key);
|
|
285
|
-
return async (...args) => {
|
|
286
|
-
if (await this.has(cacheKey)) {
|
|
287
|
-
return this.get(cacheKey);
|
|
288
|
-
}
|
|
289
|
-
const response = await asyncFunction(...args);
|
|
290
|
-
await this.set(cacheKey, response, options);
|
|
291
|
-
return response;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async set(key, value, options = {}) {
|
|
296
|
-
const wrappedValue = {
|
|
297
|
-
value,
|
|
298
|
-
tags: this.resolveTags(options.tags, value, options.params) || [],
|
|
299
|
-
timestamp: Date.now()
|
|
300
|
-
};
|
|
301
|
-
await this.send('SET', {
|
|
302
|
-
key: this.createKey(key, options.key),
|
|
303
|
-
value: wrappedValue,
|
|
304
|
-
ttl: options.ttl || 0
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async get(key) {
|
|
309
|
-
const wrappedValue = await this.send('GET', { key: this.createKey(key) });
|
|
310
|
-
return wrappedValue?.value;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
async has(key) {
|
|
314
|
-
return this.cache.has(this.createKey(key));
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
async delete(key) {
|
|
318
|
-
await this.send('DELETE', { key: this.createKey(key) });
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async clear() {
|
|
322
|
-
await this.send('CLEAR');
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
async deleteByTag(tag) {
|
|
327
|
-
for await (const [key, wrappedValue] of this.iterator()) {
|
|
328
|
-
if (wrappedValue?.tags?.includes(tag)) {
|
|
329
|
-
await this.delete(key);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Metadata
|
|
335
|
-
async metadata(key) {
|
|
336
|
-
const wrappedValue = await this.send('GET', { key: this.createKey(key) });
|
|
337
|
-
if (!wrappedValue) return null;
|
|
338
|
-
|
|
339
|
-
const { value, ...metadata } = wrappedValue;
|
|
340
|
-
return metadata;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async tags(key) {
|
|
344
|
-
const wrappedValue = await this.send('GET', { key: this.createKey(key) });
|
|
345
|
-
return wrappedValue?.tags || [];
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Iterators
|
|
349
|
-
async *iterator() {
|
|
350
|
-
for await (const [key, value] of this.cache.iterator()) {
|
|
351
|
-
if (typeof value === "string") {
|
|
352
|
-
yield [key, JSON.parse(value)];
|
|
353
|
-
} else {
|
|
354
|
-
yield [key, value];
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Resolves tags from tag configurations and data
|
|
361
|
-
* @param {Array} tagConfigs - Array of tag configuration objects
|
|
362
|
-
* @param {Object|Array} data - Data object(s) to extract data values from
|
|
363
|
-
* @param {Object} params - Parameters object to extract values from
|
|
364
|
-
* @returns {string[]} Array of resolved tags
|
|
365
|
-
*/
|
|
366
|
-
resolveTags(tagConfigs = [], data, params = {}) {
|
|
367
|
-
// Handle empty/invalid configs
|
|
368
|
-
if (!tagConfigs?.length) return [];
|
|
369
|
-
|
|
370
|
-
// Convert data to array if single object or string
|
|
371
|
-
const dataArray = !data ? [] :
|
|
372
|
-
Array.isArray(data) ? data :
|
|
373
|
-
typeof data === 'string' ? [data] : [data];
|
|
374
|
-
|
|
375
|
-
// Process each tag configuration
|
|
376
|
-
const resolvedTags = tagConfigs.flatMap(config => {
|
|
377
|
-
// Handle string tags
|
|
378
|
-
if (typeof config === 'string') {
|
|
379
|
-
return [config];
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Handle invalid/empty config objects
|
|
383
|
-
if (!config || typeof config !== 'object') {
|
|
384
|
-
return [];
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Handle static value tags
|
|
388
|
-
if (config.value) {
|
|
389
|
-
const tag = [
|
|
390
|
-
config.prefix,
|
|
391
|
-
config.value,
|
|
392
|
-
config.suffix
|
|
393
|
-
].filter(Boolean).join('');
|
|
394
|
-
return [tag];
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Handle template-based tags
|
|
398
|
-
if (config.template) {
|
|
399
|
-
const createHash = (data) => crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
|
|
400
|
-
|
|
401
|
-
const hashParts = [
|
|
402
|
-
...(data ? [data] : []),
|
|
403
|
-
...(params ? [params] : [])
|
|
404
|
-
];
|
|
405
|
-
|
|
406
|
-
const contextVars = {
|
|
407
|
-
tenant: params.tenant || 'global',
|
|
408
|
-
user: params.user || 'anonymous',
|
|
409
|
-
locale: params.locale || 'en',
|
|
410
|
-
hash: createHash(hashParts)
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
const value = config.template.replace(
|
|
414
|
-
/\{(tenant|user|locale|hash)\}/g,
|
|
415
|
-
(match, variable) => contextVars[variable]
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
const tag = [
|
|
419
|
-
config.prefix,
|
|
420
|
-
value,
|
|
421
|
-
config.suffix
|
|
422
|
-
].filter(Boolean).join('');
|
|
423
|
-
|
|
424
|
-
return [tag];
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Handle data-based tags
|
|
428
|
-
if (config.data && dataArray.length) {
|
|
429
|
-
return dataArray.flatMap(item => {
|
|
430
|
-
if (typeof item !== 'object') return [];
|
|
431
|
-
|
|
432
|
-
const dataFields = Array.isArray(config.data) ? config.data : [config.data];
|
|
433
|
-
const values = dataFields
|
|
434
|
-
.map(field => item[field])
|
|
435
|
-
.filter(Boolean);
|
|
436
|
-
|
|
437
|
-
if (!values.length) return [];
|
|
438
|
-
|
|
439
|
-
const value = values.join(config.separator || ':');
|
|
440
|
-
const tag = [
|
|
441
|
-
config.prefix,
|
|
442
|
-
value,
|
|
443
|
-
config.suffix
|
|
444
|
-
].filter(Boolean).join('');
|
|
445
|
-
return [tag];
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Handle param-based tags
|
|
450
|
-
if (config.param) {
|
|
451
|
-
const paramFields = Array.isArray(config.param) ? config.param : [config.param];
|
|
452
|
-
const values = paramFields
|
|
453
|
-
.map(field => params[field])
|
|
454
|
-
.filter(Boolean);
|
|
455
|
-
|
|
456
|
-
if (!values.length) return [];
|
|
457
|
-
|
|
458
|
-
const value = values.join(config.separator || ':');
|
|
459
|
-
const tag = [
|
|
460
|
-
config.prefix,
|
|
461
|
-
value,
|
|
462
|
-
config.suffix
|
|
463
|
-
].filter(Boolean).join('');
|
|
464
|
-
return [tag];
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return [];
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
// Remove duplicates
|
|
471
|
-
return [...new Set(resolvedTags)];
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Basic cache operations
|
|
475
|
-
createKey(keyOrObject, options = {}) {
|
|
476
|
-
|
|
477
|
-
// If the key is a string, use it
|
|
478
|
-
if (typeof keyOrObject === "string") {
|
|
479
|
-
return keyOrObject;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Otherwise, create a key based on the object
|
|
483
|
-
if (typeof keyOrObject === "object") {
|
|
484
|
-
|
|
485
|
-
// If the key is provided in the options, use it
|
|
486
|
-
if (keyOrObject.cacheKey) {
|
|
487
|
-
return keyOrObject.cacheKey;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
switch (keyOrObject.constructor.name) {
|
|
491
|
-
case "Request":
|
|
492
|
-
case "NoaRequest":
|
|
493
|
-
case "ODataRequest":
|
|
494
|
-
|
|
495
|
-
return this.createCacheKey((!options.value && !options.template) ? { template: '{tenant}:{user}:{locale}:{hash}' } : options, {
|
|
496
|
-
req: keyOrObject,
|
|
497
|
-
params: keyOrObject.params,
|
|
498
|
-
data: keyOrObject.data,
|
|
499
|
-
locale: keyOrObject.locale,
|
|
500
|
-
user: keyOrObject.user.id,
|
|
501
|
-
tenant: keyOrObject.tenant
|
|
502
|
-
});
|
|
503
|
-
case "cds.ql":
|
|
504
|
-
if (keyOrObject.SELECT) {
|
|
505
|
-
return this.createCacheKey((!options.value && !options.template) ? { template: '{hash}' } : options, { query: keyOrObject });
|
|
506
|
-
} else {
|
|
507
|
-
return undefined;
|
|
508
|
-
}
|
|
509
|
-
default:
|
|
510
|
-
return this.createCacheKey((!options.value && !options.template) ? { template: '{hash}' } : options, { data: keyOrObject });
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Creates a cache key based on configuration and context
|
|
517
|
-
* @param {Object} keyConfig - Key configuration object
|
|
518
|
-
* @param {Object} context - Context containing data, params, and request info
|
|
519
|
-
* @returns {string} Generated cache key
|
|
520
|
-
*/
|
|
521
|
-
createCacheKey(keyConfig = {}, context = {}) {
|
|
522
|
-
const { data, params, req, query, locale, user, tenant } = context;
|
|
523
|
-
|
|
524
|
-
// If a static key value is provided, use it
|
|
525
|
-
if (keyConfig.value) {
|
|
526
|
-
return keyConfig.value;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
let keyValue = '';
|
|
530
|
-
|
|
531
|
-
const hashParts = [
|
|
532
|
-
...(data ? [data] : []),
|
|
533
|
-
...(params ? [params] : []),
|
|
534
|
-
...(query ? [query] : [])
|
|
535
|
-
];
|
|
536
|
-
|
|
537
|
-
const createHash = (data) => crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
|
|
538
|
-
|
|
539
|
-
// Handle template with placeholders
|
|
540
|
-
if (keyConfig.template) {
|
|
541
|
-
const contextVars = {
|
|
542
|
-
tenant: tenant || 'global',
|
|
543
|
-
user: user || 'anonymous',
|
|
544
|
-
locale: locale || 'en',
|
|
545
|
-
hash: createHash(hashParts)
|
|
546
|
-
};
|
|
547
|
-
|
|
548
|
-
keyValue = keyConfig.template.replace(
|
|
549
|
-
/\{(tenant|user|locale|hash)\}/g,
|
|
550
|
-
(match, variable) => contextVars[variable]
|
|
551
|
-
);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// If no key value generated, create hash from input
|
|
555
|
-
if (!keyValue) {
|
|
556
|
-
keyValue = createHash(hashParts);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Combine with prefix/suffix
|
|
560
|
-
return [
|
|
561
|
-
keyConfig.prefix,
|
|
562
|
-
keyValue,
|
|
563
|
-
keyConfig.suffix
|
|
564
|
-
].filter(Boolean).join('');
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/**
|
|
568
|
-
* Executes an async function and caches its result
|
|
569
|
-
*
|
|
570
|
-
* @param {string} key - the key to cache
|
|
571
|
-
* @param {function} asyncFunction - the async function to execute
|
|
572
|
-
* @param {object} options - additional options
|
|
573
|
-
* @returns {Promise<any>} - the result
|
|
574
|
-
*/
|
|
575
|
-
async exec(key, asyncFunction, options = {}) {
|
|
576
|
-
const cacheKey = this.createKey(key, options.key);
|
|
577
|
-
if (await this.has(cacheKey)) {
|
|
578
|
-
return this.get(cacheKey);
|
|
579
|
-
}
|
|
580
|
-
const response = await asyncFunction();
|
|
581
|
-
await this.set(cacheKey, response, options);
|
|
582
|
-
return response;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
module.exports = CachingService;
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
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
|