cds-caching 0.3.2 → 1.0.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 +603 -278
- package/cds-plugin.js +2 -2
- package/index.cds +165 -20
- package/lib/CachingService.d.ts +328 -0
- package/lib/CachingService.js +369 -0
- package/lib/operations/AsyncOperations.js +172 -0
- package/lib/operations/BasicOperations.js +201 -0
- package/lib/operations/CapOperations.js +332 -0
- package/lib/support/CacheStatisticsHandler.js +1124 -0
- package/lib/support/CacheStoreManager.js +103 -0
- package/lib/support/KeyManager.js +132 -0
- package/lib/support/RuntimeConfigurationManager.js +146 -0
- package/lib/support/StatisticsPersistenceManager.js +375 -0
- package/lib/support/TagResolver.js +125 -0
- package/lib/util.js +201 -0
- package/package.json +17 -13
- package/srv/caching-api-service.js +124 -0
- package/srv/CacheStatisticsHandler.js +0 -213
- package/srv/CachingService.js +0 -588
- package/srv/statistics-service.cds +0 -37
- package/srv/statistics-service.js +0 -72
- package/srv/util.js +0 -101
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
|
|
3
|
+
// Helper to merge min values, treating 0 as unset
|
|
4
|
+
function mergeMin(a, b) {
|
|
5
|
+
if ((a === 0 || a === undefined) && (b === 0 || b === undefined)) return 0;
|
|
6
|
+
if (a === 0 || a === undefined) return b;
|
|
7
|
+
if (b === 0 || b === undefined) return a;
|
|
8
|
+
return Math.min(a, b);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Manages persistence of cache statistics to the database
|
|
13
|
+
*/
|
|
14
|
+
class StatisticsPersistenceManager {
|
|
15
|
+
constructor(cacheName, log) {
|
|
16
|
+
this.cacheName = cacheName;
|
|
17
|
+
this.log = log || cds.log('cds-caching');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Persist hourly statistics
|
|
22
|
+
* @param {object} stats - Statistics to persist
|
|
23
|
+
* @param {string} hourlyId - Hourly ID
|
|
24
|
+
* @param {string} hourlyTimestamp - Hourly timestamp
|
|
25
|
+
* @param {boolean} enabled - Whether main metrics are enabled
|
|
26
|
+
*/
|
|
27
|
+
async persistHourlyStats(stats, hourlyId, hourlyTimestamp, enabled) {
|
|
28
|
+
if (!enabled) return;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const existingHourly = await SELECT.one.from("plugin_cds_caching_Metrics")
|
|
32
|
+
.where({ ID: hourlyId, cache: this.cacheName });
|
|
33
|
+
|
|
34
|
+
if (!existingHourly) {
|
|
35
|
+
await this._createHourlyStats(stats, hourlyId, hourlyTimestamp);
|
|
36
|
+
} else {
|
|
37
|
+
await this._updateHourlyStats(stats, hourlyId, existingHourly);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
this.log.error(`Failed to persist hourly stats for cache ${this.cacheName}:`, error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Persist key metrics
|
|
46
|
+
* @param {Map} keyAccess - Key access data
|
|
47
|
+
* @param {boolean} keyMetricsEnabled - Whether key metrics are enabled
|
|
48
|
+
*/
|
|
49
|
+
async persistKeyMetrics(keyAccess, keyMetricsEnabled) {
|
|
50
|
+
if (!keyMetricsEnabled || keyAccess.size === 0) return;
|
|
51
|
+
|
|
52
|
+
this.log.debug(`Persisting ${keyAccess.size} key access records for cache ${this.cacheName}`);
|
|
53
|
+
|
|
54
|
+
for (const [key, keyStats] of keyAccess) {
|
|
55
|
+
try {
|
|
56
|
+
await this._persistKeyMetric(key, keyStats);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
this.log.error(`Failed to persist key metric for key ${key}:`, error);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Delete metrics for this cache
|
|
65
|
+
*/
|
|
66
|
+
async deleteMetrics() {
|
|
67
|
+
await DELETE('plugin_cds_caching_Metrics')
|
|
68
|
+
.where({ cache: this.cacheName });
|
|
69
|
+
this.log.debug(`Deleted metrics for cache ${this.cacheName}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Delete key metrics for this cache
|
|
74
|
+
*/
|
|
75
|
+
async deleteKeyMetrics() {
|
|
76
|
+
await DELETE('plugin_cds_caching_KeyMetrics')
|
|
77
|
+
.where({ cache: this.cacheName });
|
|
78
|
+
this.log.debug(`Deleted key metrics for cache ${this.cacheName}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create new hourly stats record
|
|
83
|
+
* @private
|
|
84
|
+
*/
|
|
85
|
+
async _createHourlyStats(stats, hourlyId, hourlyTimestamp) {
|
|
86
|
+
await INSERT.into('plugin_cds_caching_Metrics').entries([{
|
|
87
|
+
ID: hourlyId,
|
|
88
|
+
cache: this.cacheName,
|
|
89
|
+
timestamp: hourlyTimestamp,
|
|
90
|
+
period: 'hourly',
|
|
91
|
+
// Read-through metrics
|
|
92
|
+
hits: stats.hits,
|
|
93
|
+
misses: stats.misses,
|
|
94
|
+
errors: stats.errors,
|
|
95
|
+
totalRequests: stats.totalRequests,
|
|
96
|
+
|
|
97
|
+
// Read-through latency metrics
|
|
98
|
+
avgHitLatency: stats.avgHitLatency,
|
|
99
|
+
minHitLatency: stats.minHitLatency,
|
|
100
|
+
maxHitLatency: stats.maxHitLatency,
|
|
101
|
+
avgMissLatency: stats.avgMissLatency,
|
|
102
|
+
minMissLatency: stats.minMissLatency,
|
|
103
|
+
maxMissLatency: stats.maxMissLatency,
|
|
104
|
+
avgReadThroughLatency: stats.avgReadThroughLatency,
|
|
105
|
+
|
|
106
|
+
// Read-through performance metrics
|
|
107
|
+
hitRatio: stats.hitRatio,
|
|
108
|
+
throughput: stats.throughput,
|
|
109
|
+
errorRate: stats.errorRate,
|
|
110
|
+
cacheEfficiency: stats.cacheEfficiency,
|
|
111
|
+
|
|
112
|
+
// Native function metrics
|
|
113
|
+
nativeSets: stats.nativeSets,
|
|
114
|
+
nativeGets: stats.nativeGets,
|
|
115
|
+
nativeDeletes: stats.nativeDeletes,
|
|
116
|
+
nativeClears: stats.nativeClears,
|
|
117
|
+
nativeDeleteByTags: stats.nativeDeleteByTags,
|
|
118
|
+
nativeErrors: stats.nativeErrors,
|
|
119
|
+
totalNativeOperations: stats.totalNativeOperations,
|
|
120
|
+
|
|
121
|
+
// Native function performance metrics
|
|
122
|
+
nativeThroughput: stats.nativeThroughput,
|
|
123
|
+
nativeErrorRate: stats.nativeErrorRate,
|
|
124
|
+
|
|
125
|
+
// Common metrics
|
|
126
|
+
memoryUsage: stats.memoryUsage,
|
|
127
|
+
itemCount: stats.itemCount,
|
|
128
|
+
uptimeMs: stats.uptimeMs
|
|
129
|
+
}]);
|
|
130
|
+
this.log.debug(`Created new hourly stats for cache ${this.cacheName}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Update existing hourly stats record
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
async _updateHourlyStats(stats, hourlyId, existingHourly) {
|
|
138
|
+
const updatedStats = this._calculateUpdatedStats(stats, existingHourly);
|
|
139
|
+
|
|
140
|
+
await UPDATE('plugin_cds_caching_Metrics')
|
|
141
|
+
.set(updatedStats)
|
|
142
|
+
.where({ ID: hourlyId, cache: this.cacheName });
|
|
143
|
+
this.log.debug(`Updated existing hourly stats for cache ${this.cacheName}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Calculate updated stats with weighted averages
|
|
148
|
+
* @private
|
|
149
|
+
*/
|
|
150
|
+
_calculateUpdatedStats(stats, existingHourly) {
|
|
151
|
+
const updatedStats = {
|
|
152
|
+
// Read-through metrics
|
|
153
|
+
hits: existingHourly.hits + stats.hits,
|
|
154
|
+
misses: existingHourly.misses + stats.misses,
|
|
155
|
+
errors: existingHourly.errors + stats.errors,
|
|
156
|
+
totalRequests: existingHourly.totalRequests + stats.totalRequests,
|
|
157
|
+
|
|
158
|
+
// Native function metrics
|
|
159
|
+
nativeSets: existingHourly.nativeSets + stats.nativeSets,
|
|
160
|
+
nativeGets: existingHourly.nativeGets + stats.nativeGets,
|
|
161
|
+
nativeDeletes: existingHourly.nativeDeletes + stats.nativeDeletes,
|
|
162
|
+
nativeClears: existingHourly.nativeClears + stats.nativeClears,
|
|
163
|
+
nativeDeleteByTags: existingHourly.nativeDeleteByTags + stats.nativeDeleteByTags,
|
|
164
|
+
nativeErrors: existingHourly.nativeErrors + stats.nativeErrors,
|
|
165
|
+
totalNativeOperations: existingHourly.totalNativeOperations + stats.totalNativeOperations,
|
|
166
|
+
|
|
167
|
+
// Common metrics
|
|
168
|
+
memoryUsage: stats.memoryUsage,
|
|
169
|
+
itemCount: stats.itemCount,
|
|
170
|
+
uptimeMs: stats.uptimeMs
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Calculate weighted averages for latencies
|
|
174
|
+
const totalRequests = updatedStats.hits + updatedStats.misses;
|
|
175
|
+
|
|
176
|
+
// Weighted average for hit latency
|
|
177
|
+
if (updatedStats.hits > 0) {
|
|
178
|
+
const existingHitLatencySum = existingHourly.avgHitLatency * existingHourly.hits;
|
|
179
|
+
const newHitLatencySum = stats.avgHitLatency * stats.hits;
|
|
180
|
+
updatedStats.avgHitLatency = (existingHitLatencySum + newHitLatencySum) / updatedStats.hits;
|
|
181
|
+
} else {
|
|
182
|
+
updatedStats.avgHitLatency = 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Weighted average for miss latency
|
|
186
|
+
if (updatedStats.misses > 0) {
|
|
187
|
+
const existingMissLatencySum = existingHourly.avgMissLatency * existingHourly.misses;
|
|
188
|
+
const newMissLatencySum = stats.avgMissLatency * stats.misses;
|
|
189
|
+
updatedStats.avgMissLatency = (existingMissLatencySum + newMissLatencySum) / updatedStats.misses;
|
|
190
|
+
} else {
|
|
191
|
+
updatedStats.avgMissLatency = 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Weighted average for read-through latency (combined hits and misses)
|
|
195
|
+
const existingCount = (existingHourly.hits || 0) + (existingHourly.misses || 0);
|
|
196
|
+
const newCount = (stats.hits || 0) + (stats.misses || 0);
|
|
197
|
+
const totalCount = existingCount + newCount;
|
|
198
|
+
if (totalCount > 0) {
|
|
199
|
+
updatedStats.avgReadThroughLatency =
|
|
200
|
+
((existingHourly.avgReadThroughLatency || 0) * existingCount +
|
|
201
|
+
(stats.avgReadThroughLatency || 0) * newCount) / totalCount;
|
|
202
|
+
} else {
|
|
203
|
+
updatedStats.avgReadThroughLatency = 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Calculate read-through performance metrics
|
|
207
|
+
if (totalRequests > 0) {
|
|
208
|
+
updatedStats.hitRatio = (updatedStats.hits / totalRequests) * 100;
|
|
209
|
+
updatedStats.throughput = totalRequests / (stats.uptimeMs / 1000);
|
|
210
|
+
updatedStats.errorRate = (updatedStats.errors / totalRequests) * 100;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Calculate cache efficiency from weighted averages
|
|
214
|
+
if (updatedStats.avgHitLatency > 0 && updatedStats.avgMissLatency > 0) {
|
|
215
|
+
updatedStats.cacheEfficiency = updatedStats.avgMissLatency / updatedStats.avgHitLatency;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Calculate native function performance metrics
|
|
219
|
+
const totalNativeOps = updatedStats.totalNativeOperations;
|
|
220
|
+
if (totalNativeOps > 0) {
|
|
221
|
+
updatedStats.nativeThroughput = totalNativeOps / (stats.uptimeMs / 1000);
|
|
222
|
+
updatedStats.nativeErrorRate = (updatedStats.nativeErrors / totalNativeOps) * 100;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Update percentiles (use max/min of existing and current, with min using mergeMin)
|
|
226
|
+
updatedStats.maxHitLatency = Math.max(existingHourly.maxHitLatency || 0, stats.maxHitLatency || 0);
|
|
227
|
+
updatedStats.maxMissLatency = Math.max(existingHourly.maxMissLatency || 0, stats.maxMissLatency || 0);
|
|
228
|
+
updatedStats.minHitLatency = mergeMin(existingHourly.minHitLatency, stats.minHitLatency);
|
|
229
|
+
updatedStats.minMissLatency = mergeMin(existingHourly.minMissLatency, stats.minMissLatency);
|
|
230
|
+
|
|
231
|
+
return updatedStats;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Persist a single key metric
|
|
236
|
+
* @private
|
|
237
|
+
*/
|
|
238
|
+
async _persistKeyMetric(key, keyStats) {
|
|
239
|
+
const keyId = `key:${this.cacheName}:${key}`;
|
|
240
|
+
|
|
241
|
+
const existingKey = await SELECT.one.from("plugin_cds_caching_KeyMetrics")
|
|
242
|
+
.where({ ID: keyId, cache: this.cacheName, keyName: key });
|
|
243
|
+
|
|
244
|
+
if (!existingKey) {
|
|
245
|
+
await this._createKeyMetric(key, keyStats, keyId);
|
|
246
|
+
} else {
|
|
247
|
+
await this._updateKeyMetric(key, keyStats, keyId, existingKey);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Create new key metric record
|
|
253
|
+
* @private
|
|
254
|
+
*/
|
|
255
|
+
async _createKeyMetric(key, keyStats, keyId) {
|
|
256
|
+
|
|
257
|
+
await INSERT.into('plugin_cds_caching_KeyMetrics').entries([{
|
|
258
|
+
ID: keyId,
|
|
259
|
+
cache: this.cacheName,
|
|
260
|
+
keyName: key,
|
|
261
|
+
lastAccess: new Date(keyStats.lastAccess).toISOString(),
|
|
262
|
+
period: 'current',
|
|
263
|
+
// Operation type tracking
|
|
264
|
+
operationType: keyStats.operationType,
|
|
265
|
+
|
|
266
|
+
// Read-through metrics
|
|
267
|
+
hits: keyStats.hits,
|
|
268
|
+
misses: keyStats.misses,
|
|
269
|
+
errors: keyStats.errors,
|
|
270
|
+
totalRequests: keyStats.totalRequests,
|
|
271
|
+
hitRatio: keyStats.hitRatio,
|
|
272
|
+
cacheEfficiency: keyStats.cacheEfficiency,
|
|
273
|
+
|
|
274
|
+
// Read-through latency metrics
|
|
275
|
+
avgHitLatency: keyStats.avgHitLatency || 0,
|
|
276
|
+
minHitLatency: keyStats.minHitLatency === Infinity ? 0 : keyStats.minHitLatency,
|
|
277
|
+
maxHitLatency: keyStats.maxHitLatency || 0,
|
|
278
|
+
avgMissLatency: keyStats.avgMissLatency || 0,
|
|
279
|
+
minMissLatency: keyStats.minMissLatency === Infinity ? 0 : keyStats.minMissLatency,
|
|
280
|
+
maxMissLatency: keyStats.maxMissLatency || 0,
|
|
281
|
+
avgReadThroughLatency: keyStats.avgReadThroughLatency || 0,
|
|
282
|
+
|
|
283
|
+
// Read-through performance metrics
|
|
284
|
+
throughput: keyStats.throughput || 0,
|
|
285
|
+
errorRate: keyStats.errorRate || 0,
|
|
286
|
+
|
|
287
|
+
// Native function metrics
|
|
288
|
+
nativeHits: keyStats.nativeHits || 0,
|
|
289
|
+
nativeMisses: keyStats.nativeMisses || 0,
|
|
290
|
+
nativeSets: keyStats.nativeSets || 0,
|
|
291
|
+
nativeDeletes: keyStats.nativeDeletes || 0,
|
|
292
|
+
nativeClears: keyStats.nativeClears || 0,
|
|
293
|
+
nativeDeleteByTags: keyStats.nativeDeleteByTags || 0,
|
|
294
|
+
nativeErrors: keyStats.nativeErrors || 0,
|
|
295
|
+
totalNativeOperations: keyStats.totalNativeOperations || 0,
|
|
296
|
+
|
|
297
|
+
// Native function performance metrics
|
|
298
|
+
nativeThroughput: keyStats.nativeThroughput || 0,
|
|
299
|
+
nativeErrorRate: keyStats.nativeErrorRate || 0,
|
|
300
|
+
|
|
301
|
+
// Enhanced metadata
|
|
302
|
+
dataType: keyStats.dataType,
|
|
303
|
+
operation: keyStats.operation,
|
|
304
|
+
metadata: keyStats.metadata,
|
|
305
|
+
|
|
306
|
+
// Enhanced context information
|
|
307
|
+
context: keyStats.context,
|
|
308
|
+
query: keyStats.query,
|
|
309
|
+
subject: keyStats.subject,
|
|
310
|
+
target: keyStats.target,
|
|
311
|
+
tenant: keyStats.tenant,
|
|
312
|
+
user: keyStats.user,
|
|
313
|
+
locale: keyStats.locale,
|
|
314
|
+
timestamp: new Date(keyStats.timestamp).toISOString(),
|
|
315
|
+
cacheOptions: keyStats.cacheOptions
|
|
316
|
+
}]);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Update existing key metric record
|
|
321
|
+
* @private
|
|
322
|
+
*/
|
|
323
|
+
async _updateKeyMetric(key, keyStats, keyId, existingKey) {
|
|
324
|
+
const totalHits = existingKey.hits + keyStats.hits;
|
|
325
|
+
const totalMisses = existingKey.misses + keyStats.misses;
|
|
326
|
+
|
|
327
|
+
const updatedKeyStats = {
|
|
328
|
+
hits: totalHits,
|
|
329
|
+
misses: totalMisses,
|
|
330
|
+
errors: (existingKey.errors || 0) + (keyStats.errors || 0),
|
|
331
|
+
totalRequests: totalHits + totalMisses,
|
|
332
|
+
lastAccess: new Date(keyStats.lastAccess).toISOString(),
|
|
333
|
+
|
|
334
|
+
// Native function metrics
|
|
335
|
+
nativeHits: (existingKey.nativeHits || 0) + (keyStats.nativeHits || 0),
|
|
336
|
+
nativeMisses: (existingKey.nativeMisses || 0) + (keyStats.nativeMisses || 0),
|
|
337
|
+
nativeSets: (existingKey.nativeSets || 0) + (keyStats.nativeSets || 0),
|
|
338
|
+
nativeDeletes: (existingKey.nativeDeletes || 0) + (keyStats.nativeDeletes || 0),
|
|
339
|
+
nativeClears: (existingKey.nativeClears || 0) + (keyStats.nativeClears || 0),
|
|
340
|
+
nativeDeleteByTags: (existingKey.nativeDeleteByTags || 0) + (keyStats.nativeDeleteByTags || 0),
|
|
341
|
+
nativeErrors: (existingKey.nativeErrors || 0) + (keyStats.nativeErrors || 0),
|
|
342
|
+
totalNativeOperations: (existingKey.totalNativeOperations || 0) + (keyStats.totalNativeOperations || 0),
|
|
343
|
+
|
|
344
|
+
// Weighted average for hit latency
|
|
345
|
+
avgHitLatency: totalHits > 0
|
|
346
|
+
? ((existingKey.avgHitLatency * existingKey.hits) + (keyStats.avgHitLatency * keyStats.hits)) / totalHits
|
|
347
|
+
: 0,
|
|
348
|
+
// Weighted average for miss latency
|
|
349
|
+
avgMissLatency: totalMisses > 0
|
|
350
|
+
? ((existingKey.avgMissLatency * existingKey.misses) + (keyStats.avgMissLatency * keyStats.misses)) / totalMisses
|
|
351
|
+
: 0,
|
|
352
|
+
|
|
353
|
+
// Update percentiles (use max/min of existing and current, with min using mergeMin)
|
|
354
|
+
minHitLatency: mergeMin(existingKey.minHitLatency, keyStats.minHitLatency),
|
|
355
|
+
maxHitLatency: Math.max(existingKey.maxHitLatency || 0, keyStats.maxHitLatency || 0),
|
|
356
|
+
minMissLatency: mergeMin(existingKey.minMissLatency, keyStats.minMissLatency),
|
|
357
|
+
maxMissLatency: Math.max(existingKey.maxMissLatency || 0, keyStats.maxMissLatency || 0)
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Update context information if new data is available
|
|
361
|
+
if (keyStats.context) updatedKeyStats.context = keyStats.context;
|
|
362
|
+
if (keyStats.query) updatedKeyStats.query = keyStats.query;
|
|
363
|
+
if (keyStats.subject) updatedKeyStats.subject = keyStats.subject;
|
|
364
|
+
if (keyStats.target) updatedKeyStats.target = keyStats.target;
|
|
365
|
+
if (keyStats.tenant) updatedKeyStats.tenant = keyStats.tenant;
|
|
366
|
+
if (keyStats.user) updatedKeyStats.user = keyStats.user;
|
|
367
|
+
if (keyStats.locale) updatedKeyStats.locale = keyStats.locale;
|
|
368
|
+
if (keyStats.cacheOptions) updatedKeyStats.cacheOptions = keyStats.cacheOptions;
|
|
369
|
+
await UPDATE('plugin_cds_caching_KeyMetrics')
|
|
370
|
+
.set(updatedKeyStats)
|
|
371
|
+
.where({ ID: keyId, cache: this.cacheName, keyName: key });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = StatisticsPersistenceManager;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Manages cache tag resolution and generation
|
|
5
|
+
*/
|
|
6
|
+
class TagResolver {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.createHash = (data) => crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves tags from tag configurations and data
|
|
13
|
+
* @param {Array} tagConfigs - Array of tag configuration objects
|
|
14
|
+
* @param {Object|Array} data - Data object(s) to extract data values from
|
|
15
|
+
* @param {Object} params - Parameters object to extract values from
|
|
16
|
+
* @returns {string[]} Array of resolved tags
|
|
17
|
+
*/
|
|
18
|
+
resolveTags(tagConfigs = [], data, params = {}) {
|
|
19
|
+
// Handle empty/invalid configs
|
|
20
|
+
if (!tagConfigs?.length) return [];
|
|
21
|
+
|
|
22
|
+
// Convert data to array if single object or string
|
|
23
|
+
const dataArray = !data ? [] :
|
|
24
|
+
Array.isArray(data) ? data :
|
|
25
|
+
typeof data === 'string' ? [data] : [data];
|
|
26
|
+
|
|
27
|
+
// Process each tag configuration
|
|
28
|
+
const resolvedTags = tagConfigs.flatMap(config => {
|
|
29
|
+
// Handle string tags
|
|
30
|
+
if (typeof config === 'string') {
|
|
31
|
+
return [config];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Handle invalid/empty config objects
|
|
35
|
+
if (!config || typeof config !== 'object') {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle static value tags
|
|
40
|
+
if (config.value) {
|
|
41
|
+
const tag = [
|
|
42
|
+
config.prefix,
|
|
43
|
+
config.value,
|
|
44
|
+
config.suffix
|
|
45
|
+
].filter(Boolean).join('');
|
|
46
|
+
return [tag];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handle template-based tags
|
|
50
|
+
if (config.template) {
|
|
51
|
+
const hashParts = [
|
|
52
|
+
...(data ? [data] : []),
|
|
53
|
+
...(params ? [params] : [])
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const contextVars = {
|
|
57
|
+
tenant: params.tenant || 'global',
|
|
58
|
+
user: params.user || 'anonymous',
|
|
59
|
+
locale: params.locale || 'en',
|
|
60
|
+
hash: this.createHash(hashParts)
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const value = config.template.replace(
|
|
64
|
+
/\{(tenant|user|locale|hash)\}/g,
|
|
65
|
+
(match, variable) => contextVars[variable]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const tag = [
|
|
69
|
+
config.prefix,
|
|
70
|
+
value,
|
|
71
|
+
config.suffix
|
|
72
|
+
].filter(Boolean).join('');
|
|
73
|
+
|
|
74
|
+
return [tag];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Handle data-based tags
|
|
78
|
+
if (config.data && dataArray.length) {
|
|
79
|
+
return dataArray.flatMap(item => {
|
|
80
|
+
if (typeof item !== 'object') return [];
|
|
81
|
+
|
|
82
|
+
const dataFields = Array.isArray(config.data) ? config.data : [config.data];
|
|
83
|
+
const values = dataFields
|
|
84
|
+
.map(field => item[field])
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
|
|
87
|
+
if (!values.length) return [];
|
|
88
|
+
|
|
89
|
+
const value = values.join(config.separator || ':');
|
|
90
|
+
const tag = [
|
|
91
|
+
config.prefix,
|
|
92
|
+
value,
|
|
93
|
+
config.suffix
|
|
94
|
+
].filter(Boolean).join('');
|
|
95
|
+
return [tag];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Handle param-based tags
|
|
100
|
+
if (config.param) {
|
|
101
|
+
const paramFields = Array.isArray(config.param) ? config.param : [config.param];
|
|
102
|
+
const values = paramFields
|
|
103
|
+
.map(field => params[field])
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
|
|
106
|
+
if (!values.length) return [];
|
|
107
|
+
|
|
108
|
+
const value = values.join(config.separator || ':');
|
|
109
|
+
const tag = [
|
|
110
|
+
config.prefix,
|
|
111
|
+
value,
|
|
112
|
+
config.suffix
|
|
113
|
+
].filter(Boolean).join('');
|
|
114
|
+
return [tag];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return [];
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Remove duplicates
|
|
121
|
+
return [...new Set(resolvedTags)];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = TagResolver;
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
const cds = require("@sap/cds")
|
|
2
|
+
|
|
3
|
+
const extractCacheProperties = (entity, prefix) => {
|
|
4
|
+
const result = {};
|
|
5
|
+
for (const key of Object.keys(entity)) {
|
|
6
|
+
if (key.startsWith(`@cache.${prefix}.`)) {
|
|
7
|
+
const subKey = key.substring(`@cache.${prefix}.`.length);
|
|
8
|
+
result[subKey] = entity[key];
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// If there are no subproperties but the main property exists, use it directly
|
|
12
|
+
if (Object.keys(result).length === 0 && entity[`@cache.${prefix}`]) {
|
|
13
|
+
return entity[`@cache.${prefix}`];
|
|
14
|
+
}
|
|
15
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const getCachingServicesFromConfig = (srvs) => {
|
|
20
|
+
const cachingServices = [];
|
|
21
|
+
|
|
22
|
+
for (const [serviceName, serviceConfig] of Object.entries(srvs)) {
|
|
23
|
+
if (serviceConfig && serviceConfig.constructor && serviceConfig.constructor.name === "CachingService") {
|
|
24
|
+
|
|
25
|
+
const statistics = { ...serviceConfig.statistics } || {};
|
|
26
|
+
delete statistics.stats;
|
|
27
|
+
|
|
28
|
+
cachingServices.push({
|
|
29
|
+
name: serviceName,
|
|
30
|
+
impl: serviceConfig.impl || 'cds-caching',
|
|
31
|
+
store: serviceConfig.store || 'memory',
|
|
32
|
+
namespace: serviceConfig.namespace || serviceName,
|
|
33
|
+
//statistics: statistics,
|
|
34
|
+
//credentials: serviceConfig.credentials || {},
|
|
35
|
+
//...serviceConfig
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const [name, config] of Object.entries(cds.env.requires)) {
|
|
41
|
+
if (config.impl === 'cds-caching') {
|
|
42
|
+
if (cachingServices.find(service => service.name === name)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const statistics = { ...config.statistics } || {};
|
|
47
|
+
delete statistics.stats;
|
|
48
|
+
|
|
49
|
+
cachingServices.push({
|
|
50
|
+
name: name,
|
|
51
|
+
impl: config.impl || 'cds-caching',
|
|
52
|
+
store: config.store || 'memory',
|
|
53
|
+
namespace: config.namespace || name,
|
|
54
|
+
statistics: statistics,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return cachingServices;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const bindFunction = async (service, action, isBound = false) => {
|
|
63
|
+
const cache = await cds.connect.to(action['@cache.service'] || "caching");
|
|
64
|
+
cache.addCachableFunction(action.name.split('.').pop(), action, isBound);
|
|
65
|
+
|
|
66
|
+
service.prepend(function () {
|
|
67
|
+
service.on(action.name.split('.').pop(), async (req, next) => {
|
|
68
|
+
const cache = await cds.connect.to(action['@cache.service'] || "caching");
|
|
69
|
+
const { result, cacheKey } = await cache.rt.run(req, next, {
|
|
70
|
+
ttl: action['@cache.ttl'],
|
|
71
|
+
tags: action['@cache.tags'],
|
|
72
|
+
key: extractCacheProperties(action, 'key')
|
|
73
|
+
});
|
|
74
|
+
return result;
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const bindEntity = async (service, entity) => {
|
|
80
|
+
service.prepend(function () {
|
|
81
|
+
service.on('READ', entity.name, async (req, next) => {
|
|
82
|
+
const cache = await cds.connect.to(entity['@cache.service'] || "caching");
|
|
83
|
+
const { result, cacheKey } = await cache.rt.run(req, next, {
|
|
84
|
+
ttl: entity['@cache.ttl'],
|
|
85
|
+
tags: entity['@cache.tags'],
|
|
86
|
+
key: extractCacheProperties(entity, 'key')
|
|
87
|
+
});
|
|
88
|
+
return result;
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const createCacheEntry = async (cacheName, serviceConfig = {}) => {
|
|
94
|
+
try {
|
|
95
|
+
const db = await cds.connect.to('db');
|
|
96
|
+
const { Caches } = db.entities('plugin.cds_caching');
|
|
97
|
+
|
|
98
|
+
// Check if cache entry already exists
|
|
99
|
+
const existingCache = await db.read(Caches).where({ name: cacheName });
|
|
100
|
+
|
|
101
|
+
if (existingCache.length === 0) {
|
|
102
|
+
// Create new cache entry
|
|
103
|
+
const cacheEntry = {
|
|
104
|
+
name: cacheName,
|
|
105
|
+
config: JSON.stringify(serviceConfig)
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
await db.create(Caches).entries(cacheEntry);
|
|
109
|
+
cds.log('cds-caching').info(`Created cache entry for: ${cacheName}`);
|
|
110
|
+
} else {
|
|
111
|
+
// Update existing cache entry
|
|
112
|
+
await db.update(Caches)
|
|
113
|
+
.set({ config: JSON.stringify(serviceConfig) })
|
|
114
|
+
.where({ name: cacheName });
|
|
115
|
+
cds.log('cds-caching').info(`Updated cache entry for: ${cacheName}`);
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
cds.log('cds-caching').error(`Failed to create/update cache entry for ${cacheName}:`, error);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const scanCachingAnnotations = async (srvs) => {
|
|
123
|
+
LOG = cds.log('cds-caching')
|
|
124
|
+
|
|
125
|
+
// Grep all app services
|
|
126
|
+
const services = [];
|
|
127
|
+
for (const [name, config] of Object.entries(srvs)) {
|
|
128
|
+
|
|
129
|
+
const service = await cds.connect.to(name);
|
|
130
|
+
if (service.definition?.kind === 'service') {
|
|
131
|
+
services.push(service);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Grep all external services
|
|
136
|
+
for (const [name, config] of Object.entries(cds.env.requires)) {
|
|
137
|
+
if (config.kind === 'odata-v2' || config.kind === 'odata' || config.kind === 'rest') {
|
|
138
|
+
const service = await cds.connect.to(name);
|
|
139
|
+
services.push(service);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const service of services) {
|
|
144
|
+
let cacheConfig = {};
|
|
145
|
+
|
|
146
|
+
// functions
|
|
147
|
+
for (const [name, action] of Object.entries(service.actions)) {
|
|
148
|
+
if (Object.keys(action).some(key => key.startsWith('@cache')) && action.kind === 'function') {
|
|
149
|
+
LOG._debug && LOG.debug(`Caching enabled for function ${action.name}`);
|
|
150
|
+
await bindFunction(service, action);
|
|
151
|
+
|
|
152
|
+
// Collect cache configuration
|
|
153
|
+
cacheConfig.functions = cacheConfig.functions || {};
|
|
154
|
+
cacheConfig.functions[action.name] = {
|
|
155
|
+
ttl: action['@cache.ttl'],
|
|
156
|
+
tags: action['@cache.tags'],
|
|
157
|
+
key: extractCacheProperties(action, 'key')
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// entities
|
|
163
|
+
for (const entity of service.entities) {
|
|
164
|
+
if (Object.keys(entity).some(key => key.startsWith('@cache'))) {
|
|
165
|
+
await bindEntity(service, entity);
|
|
166
|
+
LOG._debug && LOG.debug(`Caching enabled for entity ${entity.name}`);
|
|
167
|
+
|
|
168
|
+
// Collect cache configuration
|
|
169
|
+
cacheConfig.entities = cacheConfig.entities || {};
|
|
170
|
+
cacheConfig.entities[entity.name] = {
|
|
171
|
+
ttl: entity['@cache.ttl'],
|
|
172
|
+
tags: entity['@cache.tags'],
|
|
173
|
+
key: extractCacheProperties(entity, 'key')
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// bound functions
|
|
178
|
+
for (const [name, action] of Object.entries(entity.actions || {})) {
|
|
179
|
+
if (Object.keys(action).some(key => key.startsWith('@cache')) && action.kind === 'function') {
|
|
180
|
+
bindFunction(service, action, true);
|
|
181
|
+
LOG._debug && LOG.debug(`Caching enabled for bound function ${action.name}`);
|
|
182
|
+
|
|
183
|
+
// Collect cache configuration
|
|
184
|
+
cacheConfig.boundFunctions = cacheConfig.boundFunctions || {};
|
|
185
|
+
cacheConfig.boundFunctions[`${entity.name}.${action.name}`] = {
|
|
186
|
+
ttl: action['@cache.ttl'],
|
|
187
|
+
tags: action['@cache.tags'],
|
|
188
|
+
key: extractCacheProperties(action, 'key')
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const cachingServices = getCachingServicesFromConfig(srvs);
|
|
196
|
+
for (const service of cachingServices) {
|
|
197
|
+
await createCacheEntry(service.name, service);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { scanCachingAnnotations }
|