cds-caching 1.2.1 → 1.3.1
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 +195 -1114
- package/app/dashboard/Component-dbg.js +59 -0
- package/app/dashboard/Component-dbg.js.map +1 -0
- package/app/dashboard/Component.js +2 -0
- package/app/dashboard/Component.js.map +1 -0
- package/app/dashboard/Component.ts +59 -0
- package/app/dashboard/controller/App-dbg.controller.js +132 -0
- package/app/dashboard/controller/App-dbg.controller.js.map +1 -0
- package/app/dashboard/controller/App.controller.js +2 -0
- package/app/dashboard/controller/App.controller.js.map +1 -0
- package/app/dashboard/controller/App.controller.ts +147 -0
- package/app/dashboard/controller/BaseController-dbg.js +75 -0
- package/app/dashboard/controller/BaseController-dbg.js.map +1 -0
- package/app/dashboard/controller/BaseController.js +2 -0
- package/app/dashboard/controller/BaseController.js.map +1 -0
- package/app/dashboard/controller/BaseController.ts +88 -0
- package/app/dashboard/controller/Cache-dbg.controller.js +421 -0
- package/app/dashboard/controller/Cache-dbg.controller.js.map +1 -0
- package/app/dashboard/controller/Cache.controller.js +2 -0
- package/app/dashboard/controller/Cache.controller.js.map +1 -0
- package/app/dashboard/controller/Cache.controller.ts +490 -0
- package/app/dashboard/controller/Main-dbg.controller.js +32 -0
- package/app/dashboard/controller/Main-dbg.controller.js.map +1 -0
- package/app/dashboard/controller/Main.controller.js +2 -0
- package/app/dashboard/controller/Main.controller.js.map +1 -0
- package/app/dashboard/controller/Main.controller.ts +31 -0
- package/app/dashboard/controller/SingleMetric-dbg.controller.js +306 -0
- package/app/dashboard/controller/SingleMetric-dbg.controller.js.map +1 -0
- package/app/dashboard/controller/SingleMetric.controller.js +2 -0
- package/app/dashboard/controller/SingleMetric.controller.js.map +1 -0
- package/app/dashboard/controller/SingleMetric.controller.ts +335 -0
- package/app/dashboard/i18n/i18n.properties +61 -0
- package/app/dashboard/i18n/i18n_de.properties +3 -0
- package/app/dashboard/i18n/i18n_en.properties +3 -0
- package/app/dashboard/index-cdn.html +28 -0
- package/app/dashboard/index.html +19 -0
- package/app/dashboard/manifest.json +145 -0
- package/app/dashboard/model/formatter-dbg.js +253 -0
- package/app/dashboard/model/formatter-dbg.js.map +1 -0
- package/app/dashboard/model/formatter.js +2 -0
- package/app/dashboard/model/formatter.js.map +1 -0
- package/app/dashboard/model/formatter.ts +275 -0
- package/app/dashboard/model/models-dbg.js +13 -0
- package/app/dashboard/model/models-dbg.js.map +1 -0
- package/app/dashboard/model/models.js +2 -0
- package/app/dashboard/model/models.js.map +1 -0
- package/app/dashboard/model/models.ts +13 -0
- package/app/dashboard/resources/sap-ui-custom-dbg.js +4695 -0
- package/app/dashboard/resources/sap-ui-custom-dbg.js.map +1 -0
- package/app/dashboard/resources/sap-ui-custom.js +5794 -0
- package/app/dashboard/resources/sap-ui-custom.js.map +1 -0
- package/app/dashboard/service/CacheStatisticsService-dbg.js +267 -0
- package/app/dashboard/service/CacheStatisticsService-dbg.js.map +1 -0
- package/app/dashboard/service/CacheStatisticsService.js +2 -0
- package/app/dashboard/service/CacheStatisticsService.js.map +1 -0
- package/app/dashboard/service/CacheStatisticsService.ts +397 -0
- package/app/dashboard/view/App.view.xml +18 -0
- package/app/dashboard/view/Cache.view.xml +777 -0
- package/app/dashboard/view/Main.view.xml +57 -0
- package/app/dashboard/view/SingleMetric.view.xml +337 -0
- package/cds-plugin.js +52 -0
- package/db/cache-store.cds +12 -0
- package/db/statistics.cds +128 -0
- package/index.cds +2 -128
- package/lib/CachingService.js +24 -10
- package/lib/add.js +17 -0
- package/lib/operations/AsyncOperations.js +162 -111
- package/lib/operations/BasicOperations.js +42 -4
- package/lib/operations/CapOperations.js +92 -40
- package/lib/support/CacheStatisticsHandler.js +35 -10
- package/lib/support/CacheStoreManager.js +29 -0
- package/lib/support/KeyManager.js +40 -2
- package/lib/support/KeyvCDS.js +149 -0
- package/lib/support/MultitenancyDetector.js +25 -0
- package/lib/support/RuntimeConfigurationManager.js +69 -37
- package/lib/support/StatisticsPersistenceManager.js +18 -2
- package/lib/support/Telemetry.js +137 -0
- package/lib/util.js +29 -11
- package/package.json +43 -11
- package/srv/caching-api-service.js +47 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const TagResolver = require('../support/TagResolver');
|
|
2
|
+
const telemetry = require('../support/Telemetry');
|
|
2
3
|
/**
|
|
3
4
|
* Manages CAP-specific cache operations
|
|
4
5
|
*/
|
|
@@ -36,10 +37,11 @@ class CapOperations {
|
|
|
36
37
|
stack: error.stack,
|
|
37
38
|
context: context
|
|
38
39
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
telemetry.recordError({ 'cache.error_operation': operationName });
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
result: null,
|
|
43
45
|
error: {
|
|
44
46
|
message: error.message,
|
|
45
47
|
operation: operationName,
|
|
@@ -51,13 +53,38 @@ class CapOperations {
|
|
|
51
53
|
|
|
52
54
|
/**
|
|
53
55
|
* Send a request with caching with read-through capabilities.
|
|
54
|
-
*
|
|
56
|
+
*
|
|
55
57
|
* @param {object} arg1 - the request object
|
|
56
58
|
* @param {Service} service - the service to send the request to
|
|
57
59
|
* @param {object} options - the options for the request
|
|
58
60
|
* @returns {Promise<any>} - the result
|
|
59
61
|
*/
|
|
60
62
|
async send(request, service, options) {
|
|
63
|
+
const tracer = telemetry.getTracer('cds-caching');
|
|
64
|
+
if (tracer) {
|
|
65
|
+
return tracer.startActiveSpan('cds-caching - send', async (span) => {
|
|
66
|
+
try {
|
|
67
|
+
const result = await this._send(request, service, options);
|
|
68
|
+
const hasCacheKey = typeof result.cacheKey === 'string' && result.cacheKey.length > 0;
|
|
69
|
+
span.setAttribute('cache.hit', result.metadata.hit);
|
|
70
|
+
if (hasCacheKey) span.setAttribute('cache.key', result.cacheKey);
|
|
71
|
+
span.setAttribute('cache.operation', 'send');
|
|
72
|
+
span.setAttribute('cache.operation_type', 'read_through');
|
|
73
|
+
if (hasCacheKey && !result.metadata.hit) span.setAttribute('cache.ttl_ms', options?.ttl || 0);
|
|
74
|
+
return result;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
span.setStatus({ code: telemetry.SpanStatusCode.ERROR, message: e.message });
|
|
77
|
+
throw e;
|
|
78
|
+
} finally {
|
|
79
|
+
span.end();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return this._send(request, service, options);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @private */
|
|
87
|
+
async _send(request, service, options) {
|
|
61
88
|
const requestOptions = {
|
|
62
89
|
ttl: 0,
|
|
63
90
|
...(options || {}),
|
|
@@ -140,9 +167,9 @@ class CapOperations {
|
|
|
140
167
|
);
|
|
141
168
|
|
|
142
169
|
if (getResult.success && getResult.result?.value !== undefined) {
|
|
143
|
-
return {
|
|
144
|
-
result: getResult.result.value,
|
|
145
|
-
cacheKey: key,
|
|
170
|
+
return {
|
|
171
|
+
result: getResult.result.value,
|
|
172
|
+
cacheKey: key,
|
|
146
173
|
metadata: { hit: true, latency: latency },
|
|
147
174
|
cacheErrors: cacheErrors
|
|
148
175
|
};
|
|
@@ -153,7 +180,7 @@ class CapOperations {
|
|
|
153
180
|
try {
|
|
154
181
|
const response = await service.send(request);
|
|
155
182
|
const totalLatency = this.getElapsedMs(startTime);
|
|
156
|
-
|
|
183
|
+
|
|
157
184
|
// Safely record miss statistics
|
|
158
185
|
const missStatsResult = await this.safeCacheOperation(
|
|
159
186
|
() => this.statistics.recordMiss(totalLatency, key, metadata),
|
|
@@ -170,7 +197,7 @@ class CapOperations {
|
|
|
170
197
|
tags: this.tagResolver.resolveTags(requestOptions.tags, response, { ...request.params, user: request.user?.id, tenant: request.tenant, locale: request.locale, hash: this.keyManager.createContentHash(request) }),
|
|
171
198
|
timestamp: Date.now()
|
|
172
199
|
};
|
|
173
|
-
|
|
200
|
+
|
|
174
201
|
const setResult = await this.safeCacheOperation(
|
|
175
202
|
() => this.cache.send("SET", { key, value: wrappedValue, ttl: requestOptions.ttl || 0 }),
|
|
176
203
|
'set',
|
|
@@ -180,9 +207,9 @@ class CapOperations {
|
|
|
180
207
|
cacheErrors.push(setResult.error);
|
|
181
208
|
}
|
|
182
209
|
|
|
183
|
-
return {
|
|
184
|
-
result: response,
|
|
185
|
-
cacheKey: key,
|
|
210
|
+
return {
|
|
211
|
+
result: response,
|
|
212
|
+
cacheKey: key,
|
|
186
213
|
metadata: { hit: false, latency: totalLatency },
|
|
187
214
|
cacheErrors: cacheErrors
|
|
188
215
|
};
|
|
@@ -206,6 +233,31 @@ class CapOperations {
|
|
|
206
233
|
* @returns {Promise<any>} - the result
|
|
207
234
|
*/
|
|
208
235
|
async run() {
|
|
236
|
+
const tracer = telemetry.getTracer('cds-caching');
|
|
237
|
+
if (tracer) {
|
|
238
|
+
return tracer.startActiveSpan('cds-caching - run', async (span) => {
|
|
239
|
+
try {
|
|
240
|
+
const result = await this._run(...arguments);
|
|
241
|
+
if (result?.cacheKey) {
|
|
242
|
+
span.setAttribute('cache.hit', result.metadata?.hit ?? false);
|
|
243
|
+
span.setAttribute('cache.key', result.cacheKey);
|
|
244
|
+
}
|
|
245
|
+
span.setAttribute('cache.operation', 'run');
|
|
246
|
+
span.setAttribute('cache.operation_type', 'read_through');
|
|
247
|
+
return result;
|
|
248
|
+
} catch (e) {
|
|
249
|
+
span.setStatus({ code: telemetry.SpanStatusCode.ERROR, message: e.message });
|
|
250
|
+
throw e;
|
|
251
|
+
} finally {
|
|
252
|
+
span.end();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return this._run(...arguments);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @private */
|
|
260
|
+
async _run() {
|
|
209
261
|
const arg1 = arguments[0];
|
|
210
262
|
if (typeof arg1 === "object") {
|
|
211
263
|
switch (arg1.constructor.name) {
|
|
@@ -226,14 +278,14 @@ class CapOperations {
|
|
|
226
278
|
|
|
227
279
|
// Track cache operation timing
|
|
228
280
|
const startTime = process.hrtime();
|
|
229
|
-
|
|
281
|
+
|
|
230
282
|
// Safely get value from cache
|
|
231
283
|
const getResult = await this.safeCacheOperation(
|
|
232
284
|
() => this.cache.send("GET", { key: req.cacheKey }),
|
|
233
285
|
'get',
|
|
234
286
|
{ key: req.cacheKey, serviceName: req.target?.name }
|
|
235
287
|
);
|
|
236
|
-
|
|
288
|
+
|
|
237
289
|
const cacheHit = getResult.success && getResult.result?.value !== undefined;
|
|
238
290
|
const cacheLatency = this.getElapsedMs(startTime);
|
|
239
291
|
const metadata = this.extractMetadataFromRequest(req);
|
|
@@ -249,11 +301,11 @@ class CapOperations {
|
|
|
249
301
|
if (!hitStatsResult.success) {
|
|
250
302
|
cacheErrors.push(hitStatsResult.error);
|
|
251
303
|
}
|
|
252
|
-
|
|
304
|
+
|
|
253
305
|
req.res?.setHeader('x-sap-cap-cache', "hit");
|
|
254
|
-
return {
|
|
255
|
-
result: getResult.result.value,
|
|
256
|
-
cacheKey: req.cacheKey,
|
|
306
|
+
return {
|
|
307
|
+
result: getResult.result.value,
|
|
308
|
+
cacheKey: req.cacheKey,
|
|
257
309
|
metadata: { hit: true, latency: cacheLatency },
|
|
258
310
|
cacheErrors: cacheErrors
|
|
259
311
|
};
|
|
@@ -272,7 +324,7 @@ class CapOperations {
|
|
|
272
324
|
if (!missStatsResult.success) {
|
|
273
325
|
cacheErrors.push(missStatsResult.error);
|
|
274
326
|
}
|
|
275
|
-
|
|
327
|
+
|
|
276
328
|
req.res?.setHeader('x-sap-cap-cache', "miss");
|
|
277
329
|
|
|
278
330
|
// Safely store in cache
|
|
@@ -281,7 +333,7 @@ class CapOperations {
|
|
|
281
333
|
tags: this.tagResolver.resolveTags(req.cacheOptions.tags, response, { ...req.params, hash: this.keyManager.createContentHash(req) }),
|
|
282
334
|
timestamp: Date.now()
|
|
283
335
|
};
|
|
284
|
-
|
|
336
|
+
|
|
285
337
|
const setResult = await this.safeCacheOperation(
|
|
286
338
|
() => this.cache.send("SET", { key: req.cacheKey, value: wrappedValue, ttl: req.cacheOptions.ttl || 0 }),
|
|
287
339
|
'set',
|
|
@@ -291,10 +343,10 @@ class CapOperations {
|
|
|
291
343
|
if (!setResult.success) {
|
|
292
344
|
cacheErrors.push(setResult.error);
|
|
293
345
|
}
|
|
294
|
-
|
|
295
|
-
return {
|
|
296
|
-
result: response,
|
|
297
|
-
cacheKey: req.cacheKey,
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
result: response,
|
|
349
|
+
cacheKey: req.cacheKey,
|
|
298
350
|
metadata: { hit: false, latency: totalLatency },
|
|
299
351
|
cacheErrors: cacheErrors
|
|
300
352
|
};
|
|
@@ -323,14 +375,14 @@ class CapOperations {
|
|
|
323
375
|
|
|
324
376
|
// Track cache operation timing
|
|
325
377
|
const startTime = process.hrtime();
|
|
326
|
-
|
|
378
|
+
|
|
327
379
|
// Safely check if key exists in cache
|
|
328
380
|
const hasCachedValueResult = await this.safeCacheOperation(
|
|
329
381
|
() => this.cache.has(query.cacheKey),
|
|
330
382
|
'has',
|
|
331
383
|
{ key: query.cacheKey, serviceName: srv?.name }
|
|
332
384
|
);
|
|
333
|
-
|
|
385
|
+
|
|
334
386
|
const hasCachedValue = hasCachedValueResult.success && hasCachedValueResult.result;
|
|
335
387
|
const cacheLatency = this.getElapsedMs(startTime);
|
|
336
388
|
const metadata = {
|
|
@@ -369,14 +421,14 @@ class CapOperations {
|
|
|
369
421
|
'get',
|
|
370
422
|
{ key: query.cacheKey }
|
|
371
423
|
);
|
|
372
|
-
|
|
424
|
+
|
|
373
425
|
if (getResult.success && getResult.result?.value !== undefined) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
426
|
+
return {
|
|
427
|
+
result: getResult.result.value,
|
|
428
|
+
cacheKey: query.cacheKey,
|
|
429
|
+
metadata: { hit: true, latency: cacheLatency },
|
|
430
|
+
cacheErrors: cacheErrors
|
|
431
|
+
};
|
|
380
432
|
}
|
|
381
433
|
}
|
|
382
434
|
|
|
@@ -401,7 +453,7 @@ class CapOperations {
|
|
|
401
453
|
tags: this.tagResolver.resolveTags(options.tags, data, { ...query.params, hash: this.keyManager.createKey(query, { serviceName: srv?.name, template: '{hash}' }) }),
|
|
402
454
|
timestamp: Date.now()
|
|
403
455
|
};
|
|
404
|
-
|
|
456
|
+
|
|
405
457
|
const setResult = await this.safeCacheOperation(
|
|
406
458
|
() => this.cache.send("SET", { key: query.cacheKey, value: wrappedValue, ttl: options.ttl || 0 }),
|
|
407
459
|
'set',
|
|
@@ -412,10 +464,10 @@ class CapOperations {
|
|
|
412
464
|
}
|
|
413
465
|
|
|
414
466
|
this.log.info('REEEESULT', { setResult, cacheErrors, wrappedValue });
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
result: data,
|
|
418
|
-
cacheKey: query.cacheKey,
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
result: data,
|
|
470
|
+
cacheKey: query.cacheKey,
|
|
419
471
|
metadata: { hit: false, latency: totalLatency },
|
|
420
472
|
cacheErrors: cacheErrors
|
|
421
473
|
};
|
|
@@ -535,4 +587,4 @@ class CapOperations {
|
|
|
535
587
|
}
|
|
536
588
|
}
|
|
537
589
|
|
|
538
|
-
module.exports = CapOperations;
|
|
590
|
+
module.exports = CapOperations;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const cds = require('@sap/cds');
|
|
2
2
|
const StatisticsPersistenceManager = require('./StatisticsPersistenceManager');
|
|
3
|
+
const telemetry = require('./Telemetry');
|
|
3
4
|
const { query } = require('@sap/cds/lib/env/defaults');
|
|
5
|
+
const { isPluginModelAvailable } = require('../util');
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Class to handle all things caching statistics.
|
|
@@ -118,6 +120,9 @@ class CacheStatisticsHandler {
|
|
|
118
120
|
this.recordKeyAccess(key, 'hit', { ...metadata, latency });
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
// Emit OTel metric (independent of metricsEnabled)
|
|
124
|
+
telemetry.recordHit(latency, { 'cache.name': this.options.cache });
|
|
125
|
+
|
|
121
126
|
// Log for debugging
|
|
122
127
|
this.log.debug(`Recorded HIT for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
|
|
123
128
|
}
|
|
@@ -141,6 +146,9 @@ class CacheStatisticsHandler {
|
|
|
141
146
|
this.recordKeyAccess(key, 'miss', { ...metadata, latency });
|
|
142
147
|
}
|
|
143
148
|
|
|
149
|
+
// Emit OTel metric (independent of metricsEnabled)
|
|
150
|
+
telemetry.recordMiss(latency, { 'cache.name': this.options.cache });
|
|
151
|
+
|
|
144
152
|
// Log for debugging
|
|
145
153
|
this.log.debug(`Recorded MISS for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
|
|
146
154
|
}
|
|
@@ -164,6 +172,9 @@ class CacheStatisticsHandler {
|
|
|
164
172
|
this.recordKeyAccess(key, 'set', { ...metadata, latency });
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
// Emit OTel metric (independent of metricsEnabled)
|
|
176
|
+
telemetry.recordSet({ 'cache.name': this.options.cache });
|
|
177
|
+
|
|
167
178
|
// Log for debugging
|
|
168
179
|
this.log.debug(`Recorded SET for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
|
|
169
180
|
}
|
|
@@ -187,6 +198,9 @@ class CacheStatisticsHandler {
|
|
|
187
198
|
this.recordKeyAccess(key, 'delete', { ...metadata, latency });
|
|
188
199
|
}
|
|
189
200
|
|
|
201
|
+
// Emit OTel metric (independent of metricsEnabled)
|
|
202
|
+
telemetry.recordDelete({ 'cache.name': this.options.cache });
|
|
203
|
+
|
|
190
204
|
// Log for debugging
|
|
191
205
|
this.log.debug(`Recorded DELETE for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
|
|
192
206
|
}
|
|
@@ -210,6 +224,9 @@ class CacheStatisticsHandler {
|
|
|
210
224
|
this.recordKeyAccess(key, 'nativeSet', metadata);
|
|
211
225
|
}
|
|
212
226
|
|
|
227
|
+
// Emit OTel metric (independent of metricsEnabled)
|
|
228
|
+
telemetry.recordSet({ 'cache.name': this.options.cache });
|
|
229
|
+
|
|
213
230
|
// Log for debugging
|
|
214
231
|
this.log.debug(`Recorded NATIVE SET for key: ${key}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
|
|
215
232
|
}
|
|
@@ -257,6 +274,9 @@ class CacheStatisticsHandler {
|
|
|
257
274
|
this.recordKeyAccess(key, 'nativeDelete', metadata);
|
|
258
275
|
}
|
|
259
276
|
|
|
277
|
+
// Emit OTel metric (independent of metricsEnabled)
|
|
278
|
+
telemetry.recordDelete({ 'cache.name': this.options.cache });
|
|
279
|
+
|
|
260
280
|
// Log for debugging
|
|
261
281
|
this.log.debug(`Recorded NATIVE DELETE for key: ${key}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
|
|
262
282
|
}
|
|
@@ -301,6 +321,9 @@ class CacheStatisticsHandler {
|
|
|
301
321
|
if (this.options.metricsEnabled) {
|
|
302
322
|
this.stats.current.errors++;
|
|
303
323
|
}
|
|
324
|
+
|
|
325
|
+
// Emit OTel metric (independent of metricsEnabled)
|
|
326
|
+
telemetry.recordError({ 'cache.name': this.options.cache });
|
|
304
327
|
}
|
|
305
328
|
|
|
306
329
|
/**
|
|
@@ -652,8 +675,8 @@ class CacheStatisticsHandler {
|
|
|
652
675
|
* @returns {Array} - array of all tracked keys with enhanced data
|
|
653
676
|
*/
|
|
654
677
|
async getAllTrackedKeys() {
|
|
655
|
-
// If key metrics is enabled, try to get from database first
|
|
656
|
-
if (this.options.keyMetricsEnabled) {
|
|
678
|
+
// If key metrics is enabled and the model is available, try to get from database first
|
|
679
|
+
if (this.options.keyMetricsEnabled && isPluginModelAvailable()) {
|
|
657
680
|
try {
|
|
658
681
|
const dbKeys = await SELECT.from("plugin_cds_caching_KeyMetrics")
|
|
659
682
|
.where({ cache: this.options.cache });
|
|
@@ -729,8 +752,8 @@ class CacheStatisticsHandler {
|
|
|
729
752
|
* @returns {object|null} - detailed key information or null if not found
|
|
730
753
|
*/
|
|
731
754
|
async getKeyDetails(keyName) {
|
|
732
|
-
// If key metrics is enabled, try to get from database first
|
|
733
|
-
if (this.options.keyMetricsEnabled) {
|
|
755
|
+
// If key metrics is enabled and the model is available, try to get from database first
|
|
756
|
+
if (this.options.keyMetricsEnabled && isPluginModelAvailable()) {
|
|
734
757
|
try {
|
|
735
758
|
const keyDetails = await SELECT.one.from("plugin_cds_caching_KeyMetrics")
|
|
736
759
|
.where({ cache: this.options.cache, keyName: keyName });
|
|
@@ -938,7 +961,7 @@ class CacheStatisticsHandler {
|
|
|
938
961
|
}
|
|
939
962
|
|
|
940
963
|
async getMetrics(period = 'hourly', from, to) {
|
|
941
|
-
if (!this.options.metricsEnabled) return null;
|
|
964
|
+
if (!this.options.metricsEnabled || !isPluginModelAvailable()) return null;
|
|
942
965
|
|
|
943
966
|
const query = SELECT.from("plugin_cds_caching_Metrics")
|
|
944
967
|
.where({ period: period });
|
|
@@ -952,7 +975,7 @@ class CacheStatisticsHandler {
|
|
|
952
975
|
}
|
|
953
976
|
|
|
954
977
|
async getKeyMetrics(key, from, to) {
|
|
955
|
-
if (!this.options.keyMetricsEnabled) return null;
|
|
978
|
+
if (!this.options.keyMetricsEnabled || !isPluginModelAvailable()) return null;
|
|
956
979
|
const query = SELECT.from("plugin_cds_caching_KeyMetrics")
|
|
957
980
|
.where({ cache: this.options.cache, keyName: key });
|
|
958
981
|
|
|
@@ -1093,14 +1116,16 @@ class CacheStatisticsHandler {
|
|
|
1093
1116
|
}
|
|
1094
1117
|
|
|
1095
1118
|
async clearMetrics() {
|
|
1096
|
-
|
|
1097
|
-
|
|
1119
|
+
if (isPluginModelAvailable()) {
|
|
1120
|
+
await this.persistenceManager.deleteMetrics();
|
|
1121
|
+
}
|
|
1098
1122
|
this.resetCurrentStats();
|
|
1099
1123
|
}
|
|
1100
1124
|
|
|
1101
1125
|
async clearKeyMetrics() {
|
|
1102
|
-
|
|
1103
|
-
|
|
1126
|
+
if (isPluginModelAvailable()) {
|
|
1127
|
+
await this.persistenceManager.deleteKeyMetrics();
|
|
1128
|
+
}
|
|
1104
1129
|
this.resetCurrentStats();
|
|
1105
1130
|
}
|
|
1106
1131
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const { Keyv } = require('keyv');
|
|
2
2
|
const { requireOptional, requireAnyOptional } = require('./optionalRequire');
|
|
3
|
+
const KeyvCDS = require('./KeyvCDS');
|
|
4
|
+
const { isMultitenantMode } = require('./MultitenancyDetector');
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Manages cache store initialization and configuration
|
|
@@ -81,6 +83,33 @@ class CacheStoreManager {
|
|
|
81
83
|
...(options.credentials?.table ? { table: options.credentials?.table } : {}),
|
|
82
84
|
...{ throwOnConnectErrors: options.throwOnErrors, useKeyPrefix: false, throwOnErrors: options.throwOnErrors }
|
|
83
85
|
});
|
|
86
|
+
case "cds":
|
|
87
|
+
return new KeyvCDS(options);
|
|
88
|
+
case "hana":
|
|
89
|
+
if (isMultitenantMode()) {
|
|
90
|
+
this.log.warn(
|
|
91
|
+
`Store 'hana' uses direct connections that bypass CAP's tenant-aware connection management. ` +
|
|
92
|
+
`Consider using store: 'cds' instead for multi-tenant deployments.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const KeyvHana = requireOptional('keyv-hana', { feature: 'store', value: 'hana' });
|
|
96
|
+
// BTP HANA service bindings use "user"/"password"; keyv-hana expects "uid"/"pwd"
|
|
97
|
+
return new KeyvHana({
|
|
98
|
+
host: options.credentials?.host,
|
|
99
|
+
port: options.credentials?.port,
|
|
100
|
+
uid: options.credentials?.uid || options.credentials?.user,
|
|
101
|
+
pwd: options.credentials?.pwd || options.credentials?.password,
|
|
102
|
+
schema: options.credentials?.schema,
|
|
103
|
+
table: options.credentials?.table || 'KEYV',
|
|
104
|
+
keySize: options.credentials?.keySize || 255,
|
|
105
|
+
createTable: options.credentials?.createTable ?? false,
|
|
106
|
+
connectOptions: {
|
|
107
|
+
...(options.credentials?.certificate ? { ca: options.credentials.certificate } : {}),
|
|
108
|
+
...(options.credentials?.encrypt !== undefined ? { encrypt: options.credentials.encrypt } : {}),
|
|
109
|
+
...(options.credentials?.sslValidateCertificate !== undefined ? { sslValidateCertificate: options.credentials.sslValidateCertificate } : {}),
|
|
110
|
+
...options.credentials?.connectOptions,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
84
113
|
default:
|
|
85
114
|
return new Map();
|
|
86
115
|
}
|
|
@@ -78,13 +78,34 @@ class KeyManager {
|
|
|
78
78
|
case "Request":
|
|
79
79
|
case "NoaRequest":
|
|
80
80
|
case "ODataRequest":
|
|
81
|
-
//
|
|
81
|
+
// Hash request information including the HTTP URL which
|
|
82
|
+
// contains $filter/$select/$orderby query parameters.
|
|
83
|
+
//
|
|
84
|
+
// Why req.query (CQN) is unreliable for hashing:
|
|
85
|
+
// NoaRequest ("New OData Adapter Request", defined in
|
|
86
|
+
// @sap/cds/libx/odata/ODataAdapter.js) carries cds.ql
|
|
87
|
+
// query objects where SELECT.where lives on the prototype
|
|
88
|
+
// chain, not as an own property. JSON.stringify only
|
|
89
|
+
// serializes own enumerable properties, so the where clause
|
|
90
|
+
// from $filter is silently dropped — two requests that
|
|
91
|
+
// differ only by $filter produce the same hash.
|
|
92
|
+
// This does NOT affect programmatic CQL (e.g.
|
|
93
|
+
// SELECT.from(Entity).where(...)) where `where` is an own
|
|
94
|
+
// property. It only affects CQN built from OData URL
|
|
95
|
+
// parsing in the adapter pipeline.
|
|
96
|
+
//
|
|
97
|
+
// The HTTP URL (req._.req.url / req.http.req.url) always
|
|
98
|
+
// contains the full query string as a plain string and is
|
|
99
|
+
// the reliable source for all OData query options. When a
|
|
100
|
+
// URL is available it supersedes req.query.
|
|
101
|
+
const url = this.normalizeUrl(keyOrObject._?.req?.url || keyOrObject.http?.req?.url);
|
|
82
102
|
return this.createHash({
|
|
83
103
|
method: keyOrObject.method,
|
|
84
104
|
path: keyOrObject.path || keyOrObject.http?.req?.path,
|
|
105
|
+
url,
|
|
85
106
|
data: keyOrObject.data,
|
|
86
107
|
params: keyOrObject.params,
|
|
87
|
-
query: keyOrObject.query,
|
|
108
|
+
query: url ? undefined : keyOrObject.query,
|
|
88
109
|
event: keyOrObject.event,
|
|
89
110
|
target: keyOrObject.target?.name
|
|
90
111
|
});
|
|
@@ -105,6 +126,23 @@ class KeyManager {
|
|
|
105
126
|
return this.createHash({ content: keyOrObject });
|
|
106
127
|
}
|
|
107
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Normalize a URL by sorting its query parameters alphabetically by key.
|
|
131
|
+
* Ensures the same parameters in different order produce the same cache key.
|
|
132
|
+
* @param {string} url - URL string to normalize
|
|
133
|
+
* @returns {string|undefined} - Normalized URL or undefined if input is falsy
|
|
134
|
+
*/
|
|
135
|
+
normalizeUrl(url) {
|
|
136
|
+
if (!url) return undefined;
|
|
137
|
+
try {
|
|
138
|
+
const urlObj = new URL(url, 'http://localhost');
|
|
139
|
+
urlObj.searchParams.sort();
|
|
140
|
+
return urlObj.pathname + (urlObj.searchParams.size ? '?' + urlObj.searchParams.toString() : '');
|
|
141
|
+
} catch {
|
|
142
|
+
return url;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
108
146
|
/**
|
|
109
147
|
* Serialize function arguments for cache key generation
|
|
110
148
|
* @param {any} arg - argument to serialize
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keyv-compatible store adapter that uses CAP's managed DB connection (CQL).
|
|
5
|
+
*
|
|
6
|
+
* Benefits:
|
|
7
|
+
* - Works with any DB backend CAP supports (HANA, PostgreSQL, SQLite)
|
|
8
|
+
* - In MTX mode, automatically routes to the correct tenant's HDI container
|
|
9
|
+
* - No separate credentials needed — reuses the app's existing DB connection
|
|
10
|
+
*/
|
|
11
|
+
class KeyvCDS {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.log = cds.log('cds-caching');
|
|
14
|
+
this._entity = null;
|
|
15
|
+
// Keyv checks store.opts.dialect to enable iterator support
|
|
16
|
+
this.opts = { dialect: 'sqlite', url: '' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Lazily resolve the CacheStore entity and DB service.
|
|
21
|
+
* Both are only available after the CDS server has fully bootstrapped.
|
|
22
|
+
* @private
|
|
23
|
+
*/
|
|
24
|
+
async _getDB() {
|
|
25
|
+
if (!cds.db) await cds.connect.to('db');
|
|
26
|
+
const db = cds.db;
|
|
27
|
+
if (!this._entity) {
|
|
28
|
+
this._entity = cds.entities('plugin.cds_caching')?.CacheStore;
|
|
29
|
+
}
|
|
30
|
+
return { db, entity: this._entity };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get a value by key.
|
|
35
|
+
* Returns undefined if the key does not exist or is expired.
|
|
36
|
+
*/
|
|
37
|
+
async get(key) {
|
|
38
|
+
try {
|
|
39
|
+
const { db, entity } = await this._getDB();
|
|
40
|
+
if (!db || !entity) return undefined;
|
|
41
|
+
const row = await db.run(SELECT.one.from(entity).where({ ID: key }));
|
|
42
|
+
if (!row) return undefined;
|
|
43
|
+
if (row.expiresAt && row.expiresAt <= Date.now()) {
|
|
44
|
+
await db.run(DELETE.from(entity).where({ ID: key }));
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return row.value;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
this.log.error(`KeyvCDS get error for key ${key}:`, error);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set a key-value pair with optional TTL (in milliseconds).
|
|
56
|
+
*/
|
|
57
|
+
async set(key, value, ttl) {
|
|
58
|
+
try {
|
|
59
|
+
const { db, entity } = await this._getDB();
|
|
60
|
+
if (!db || !entity) return;
|
|
61
|
+
const expiresAt = ttl ? Date.now() + ttl : null;
|
|
62
|
+
const existing = await db.run(SELECT.one.from(entity).columns('ID').where({ ID: key }));
|
|
63
|
+
if (existing) {
|
|
64
|
+
await db.run(UPDATE(entity).set({ value, expiresAt }).where({ ID: key }));
|
|
65
|
+
} else {
|
|
66
|
+
await db.run(INSERT.into(entity).entries({ ID: key, value, expiresAt }));
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.log.error(`KeyvCDS set error for key ${key}:`, error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Delete a key. Returns true if the key existed, false otherwise.
|
|
75
|
+
*/
|
|
76
|
+
async delete(key) {
|
|
77
|
+
try {
|
|
78
|
+
const { db, entity } = await this._getDB();
|
|
79
|
+
if (!db || !entity) return false;
|
|
80
|
+
const result = await db.run(DELETE.from(entity).where({ ID: key }));
|
|
81
|
+
return !!result;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
this.log.error(`KeyvCDS delete error for key ${key}:`, error);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Clear all entries from the store.
|
|
90
|
+
*/
|
|
91
|
+
async clear() {
|
|
92
|
+
try {
|
|
93
|
+
const { db, entity } = await this._getDB();
|
|
94
|
+
if (!db || !entity) return;
|
|
95
|
+
await db.run(DELETE.from(entity));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.log.error('KeyvCDS clear error:', error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check if a key exists and is not expired.
|
|
103
|
+
*/
|
|
104
|
+
async has(key) {
|
|
105
|
+
try {
|
|
106
|
+
const { db, entity } = await this._getDB();
|
|
107
|
+
if (!db || !entity) return false;
|
|
108
|
+
const row = await db.run(SELECT.one.from(entity).columns('ID', 'expiresAt').where({ ID: key }));
|
|
109
|
+
if (!row) return false;
|
|
110
|
+
if (row.expiresAt && row.expiresAt <= Date.now()) {
|
|
111
|
+
await db.run(DELETE.from(entity).where({ ID: key }));
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.log.error(`KeyvCDS has error for key ${key}:`, error);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Async iterator over all non-expired [key, value] pairs.
|
|
123
|
+
*/
|
|
124
|
+
async *iterator() {
|
|
125
|
+
try {
|
|
126
|
+
const { db, entity } = await this._getDB();
|
|
127
|
+
if (!db || !entity) return;
|
|
128
|
+
const rows = await db.run(SELECT.from(entity));
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
if (row.expiresAt && row.expiresAt <= now) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
yield [row.ID, row.value];
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
this.log.error('KeyvCDS iterator error:', error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* No-op — CAP manages the DB connection lifecycle.
|
|
143
|
+
*/
|
|
144
|
+
async disconnect() {
|
|
145
|
+
// Nothing to do; the DB connection is managed by the CAP runtime
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = KeyvCDS;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detects whether the application is running in multi-tenant mode (MTX)
|
|
5
|
+
* and whether a tenant context is currently available.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if the application is configured for multi-tenancy.
|
|
10
|
+
* Returns true when @sap/cds-mtxs sets cds.env.requires.multitenancy.
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
function isMultitenantMode() {
|
|
14
|
+
return !!(cds.env.requires?.multitenancy || cds.env.requires?.['cds.xt.SaasProvisioningService']);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if a tenant context is currently available (i.e., we are inside a tenant request).
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
function hasTenantContext() {
|
|
22
|
+
return !!cds.context?.tenant;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { isMultitenantMode, hasTenantContext };
|