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.
@@ -0,0 +1,1124 @@
1
+ const cds = require('@sap/cds');
2
+ const StatisticsPersistenceManager = require('./StatisticsPersistenceManager');
3
+ const { query } = require('@sap/cds/lib/env/defaults');
4
+
5
+ /**
6
+ * Class to handle all things caching statistics.
7
+ */
8
+ class CacheStatisticsHandler {
9
+
10
+ log = cds.log('cds-caching');
11
+
12
+ constructor(options = {}) {
13
+ this.options = {
14
+ persistenceInterval: 10 * 1000, // 10 seconds
15
+ maxLatencies: 2000,
16
+ maxKeyMetrics: 1000, // Track top accessed keys
17
+ keyMetricsEnabled: false,
18
+ metricsEnabled: false, // Main metrics enabled flag
19
+ ...options
20
+ };
21
+
22
+ this.stats = {
23
+ current: {
24
+ // Read-through metrics (high value with latencies)
25
+ hits: 0,
26
+ misses: 0,
27
+ sets: 0,
28
+ deletes: 0,
29
+ errors: 0,
30
+ totalRequests: 0,
31
+ latencies: [],
32
+ hitLatencies: [], // Separate latency tracking for hits
33
+ missLatencies: [], // Separate latency tracking for misses
34
+ setLatencies: [], // Separate latency tracking for sets
35
+ deleteLatencies: [], // Separate latency tracking for deletes
36
+
37
+ // Native function metrics (basic counts only)
38
+ nativeSets: 0,
39
+ nativeGets: 0,
40
+ nativeDeletes: 0,
41
+ nativeClears: 0,
42
+ nativeDeleteByTags: 0,
43
+ nativeErrors: 0,
44
+ totalNativeOperations: 0,
45
+
46
+ keyAccess: new Map(), // Track key access patterns
47
+ startTime: Date.now(),
48
+ lastReset: Date.now()
49
+ },
50
+ historical: {
51
+ hourly: new Map(),
52
+ daily: new Map()
53
+ },
54
+ lastPersisted: Date.now()
55
+ };
56
+
57
+ // Initialize persistence manager
58
+ this.persistenceManager = new StatisticsPersistenceManager(this.options.cache, this.log);
59
+
60
+ // Always set up persistence interval, but control execution based on metricsEnabled flag
61
+ this.setupPersistenceInterval();
62
+
63
+ cds.on('shutdown', () => {
64
+ this.cleanupPersistenceInterval();
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Set up the persistence interval
70
+ */
71
+ setupPersistenceInterval() {
72
+ // Clear any existing interval
73
+ this.cleanupPersistenceInterval();
74
+ this.persistInterval = setInterval(async () => {
75
+ // Only persist if at least one type of metrics is enabled
76
+ if (this.options.metricsEnabled || this.options.keyMetricsEnabled) {
77
+ await this.persistMetrics();
78
+ }
79
+ }, this.options.persistenceInterval);
80
+
81
+ }
82
+
83
+ /**
84
+ * Clean up the persistence interval
85
+ */
86
+ cleanupPersistenceInterval() {
87
+ if (this.persistInterval) {
88
+ clearInterval(this.persistInterval);
89
+ this.persistInterval = null;
90
+ }
91
+ }
92
+
93
+ enableKeyMetrics(enabled) {
94
+ this.options.keyMetricsEnabled = enabled;
95
+ }
96
+
97
+
98
+ enableMetrics(enabled) {
99
+ this.options.metricsEnabled = enabled;
100
+ }
101
+
102
+ /**
103
+ * Record a hit
104
+ * @param {number} latency - the latency of the hit
105
+ * @param {string} key - the key of the hit
106
+ * @param {object} metadata - the metadata of the hit
107
+ */
108
+ recordHit(latency, key, metadata = {}) {
109
+ // Record basic metrics if enabled
110
+ if (this.options.metricsEnabled) {
111
+ this.stats.current.hits++;
112
+ this.recordLatency(latency);
113
+ this.recordHitLatency(latency);
114
+ }
115
+
116
+ // Record key access if key metrics is enabled (independent of main metrics)
117
+ if (this.options.keyMetricsEnabled && key) {
118
+ this.recordKeyAccess(key, 'hit', { ...metadata, latency });
119
+ }
120
+
121
+ // Log for debugging
122
+ this.log.debug(`Recorded HIT for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
123
+ }
124
+
125
+ /**
126
+ * Record a miss
127
+ * @param {number} latency - the latency of the miss
128
+ * @param {string} key - the key of the miss
129
+ * @param {object} metadata - the metadata of the miss
130
+ */
131
+ recordMiss(latency, key, metadata = {}) {
132
+ // Record basic metrics if enabled
133
+ if (this.options.metricsEnabled) {
134
+ this.stats.current.misses++;
135
+ this.recordLatency(latency);
136
+ this.recordMissLatency(latency);
137
+ }
138
+
139
+ // Record key access if key metrics is enabled (independent of main metrics)
140
+ if (this.options.keyMetricsEnabled && key) {
141
+ this.recordKeyAccess(key, 'miss', { ...metadata, latency });
142
+ }
143
+
144
+ // Log for debugging
145
+ this.log.debug(`Recorded MISS for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
146
+ }
147
+
148
+ /**
149
+ * Record a set
150
+ * @param {number} latency - the latency of the set
151
+ * @param {string} key - the key of the set
152
+ * @param {object} metadata - the metadata of the set
153
+ */
154
+ recordSet(latency, key, metadata = {}) {
155
+ // Record basic metrics if enabled
156
+ if (this.options.metricsEnabled) {
157
+ this.stats.current.sets++;
158
+ this.recordLatency(latency);
159
+ this.recordSetLatency(latency);
160
+ }
161
+
162
+ // Record key access if key metrics is enabled (independent of main metrics)
163
+ if (this.options.keyMetricsEnabled && key) {
164
+ this.recordKeyAccess(key, 'set', { ...metadata, latency });
165
+ }
166
+
167
+ // Log for debugging
168
+ this.log.debug(`Recorded SET for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
169
+ }
170
+
171
+ /**
172
+ * Record a delete
173
+ * @param {number} latency - the latency of the delete
174
+ * @param {string} key - the key of the delete
175
+ * @param {string} metadata - the metadata of the delete
176
+ */
177
+ recordDelete(latency, key, metadata = {}) {
178
+ // Record basic metrics if enabled
179
+ if (this.options.metricsEnabled) {
180
+ this.stats.current.deletes++;
181
+ this.recordLatency(latency);
182
+ this.recordDeleteLatency(latency);
183
+ }
184
+
185
+ // Record key access if key metrics is enabled (independent of main metrics)
186
+ if (this.options.keyMetricsEnabled && key) {
187
+ this.recordKeyAccess(key, 'delete', { ...metadata, latency });
188
+ }
189
+
190
+ // Log for debugging
191
+ this.log.debug(`Recorded DELETE for key: ${key}, latency: ${latency}ms, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
192
+ }
193
+
194
+ /**
195
+ * Record a native set operation (cache-aside)
196
+ * @param {string} key - the key of the set
197
+ * @param {object} metadata - the metadata of the set
198
+ */
199
+ recordNativeSet(key, metadata = {}) {
200
+ this.log.debug(`recordNativeSet called with key: ${key}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
201
+
202
+ // Record basic metrics if enabled
203
+ if (this.options.metricsEnabled) {
204
+ this.stats.current.nativeSets++;
205
+ this.stats.current.totalNativeOperations++;
206
+ }
207
+
208
+ // Record key access if key metrics is enabled (independent of main metrics)
209
+ if (this.options.keyMetricsEnabled && key) {
210
+ this.recordKeyAccess(key, 'nativeSet', metadata);
211
+ }
212
+
213
+ // Log for debugging
214
+ this.log.debug(`Recorded NATIVE SET for key: ${key}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
215
+ }
216
+
217
+ /**
218
+ * Record a native get operation (cache-aside)
219
+ * @param {string} key - the key of the get
220
+ * @param {boolean} hit - whether it was a hit or miss
221
+ * @param {object} metadata - the metadata of the get
222
+ */
223
+ recordNativeGet(key, hit, metadata = {}) {
224
+ this.log.debug(`recordNativeGet called with key: ${key}, hit: ${hit}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
225
+
226
+ // Record basic metrics if enabled
227
+ if (this.options.metricsEnabled) {
228
+ this.stats.current.nativeGets++;
229
+ this.stats.current.totalNativeOperations++;
230
+ }
231
+
232
+ // Record key access if key metrics is enabled (independent of main metrics)
233
+ if (this.options.keyMetricsEnabled && key) {
234
+ this.recordKeyAccess(key, hit ? 'nativeHit' : 'nativeMiss', metadata);
235
+ }
236
+
237
+ // Log for debugging
238
+ this.log.debug(`Recorded NATIVE GET for key: ${key}, hit: ${hit}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
239
+ }
240
+
241
+ /**
242
+ * Record a native delete operation (cache-aside)
243
+ * @param {string} key - the key of the delete
244
+ * @param {object} metadata - the metadata of the delete
245
+ */
246
+ recordNativeDelete(key, metadata = {}) {
247
+ this.log.debug(`recordNativeDelete called with key: ${key}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
248
+
249
+ // Record basic metrics if enabled
250
+ if (this.options.metricsEnabled) {
251
+ this.stats.current.nativeDeletes++;
252
+ this.stats.current.totalNativeOperations++;
253
+ }
254
+
255
+ // Record key access if key metrics is enabled (independent of main metrics)
256
+ if (this.options.keyMetricsEnabled && key) {
257
+ this.recordKeyAccess(key, 'nativeDelete', metadata);
258
+ }
259
+
260
+ // Log for debugging
261
+ this.log.debug(`Recorded NATIVE DELETE for key: ${key}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
262
+ }
263
+
264
+ /**
265
+ * Record a native clear operation (cache-aside)
266
+ * @param {object} metadata - the metadata of the clear
267
+ */
268
+ recordNativeClear(metadata = {}) {
269
+ // Record basic metrics if enabled
270
+ if (this.options.metricsEnabled) {
271
+ this.stats.current.nativeClears++;
272
+ this.stats.current.totalNativeOperations++;
273
+ }
274
+
275
+ // Log for debugging
276
+ this.log.debug(`Recorded NATIVE CLEAR, enabled: ${this.options.metricsEnabled}`);
277
+ }
278
+
279
+ /**
280
+ * Record a native deleteByTag operation (cache-aside)
281
+ * @param {string} tag - the tag used for deletion
282
+ * @param {object} metadata - the metadata of the deleteByTag
283
+ */
284
+ recordNativeDeleteByTag(tag, metadata = {}) {
285
+ // Record basic metrics if enabled
286
+ if (this.options.metricsEnabled) {
287
+ this.stats.current.nativeDeleteByTags++;
288
+ this.stats.current.totalNativeOperations++;
289
+ }
290
+
291
+ // Log for debugging
292
+ this.log.debug(`Recorded NATIVE DELETE BY TAG: ${tag}, enabled: ${this.options.metricsEnabled}`);
293
+ }
294
+
295
+ /**
296
+ * Record an error
297
+ * @param {Error} error - the error to record
298
+ */
299
+ recordError(error) {
300
+ // Record basic metrics if enabled
301
+ if (this.options.metricsEnabled) {
302
+ this.stats.current.errors++;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Record a native error
308
+ * @param {Error} error - the error to record
309
+ */
310
+ recordNativeError(error) {
311
+ // Record basic metrics if enabled
312
+ if (this.options.metricsEnabled) {
313
+ this.stats.current.nativeErrors++;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Record a latency
319
+ * @param {number} ms - the latency to record
320
+ */
321
+ recordLatency(ms) {
322
+ // Only record latency if main metrics are enabled
323
+ if (this.options.metricsEnabled) {
324
+ this.stats.current.latencies.push(ms);
325
+ if (this.stats.current.latencies.length > this.options.maxLatencies) {
326
+ this.stats.current.latencies.shift();
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Record a hit latency
333
+ * @param {number} ms - the latency to record
334
+ */
335
+ recordHitLatency(ms) {
336
+ // Only record latency if main metrics are enabled
337
+ if (this.options.metricsEnabled) {
338
+ this.stats.current.hitLatencies.push(ms);
339
+ if (this.stats.current.hitLatencies.length > this.options.maxLatencies) {
340
+ this.stats.current.hitLatencies.shift();
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Record a miss latency
347
+ * @param {number} ms - the latency to record
348
+ */
349
+ recordMissLatency(ms) {
350
+ // Only record latency if main metrics are enabled
351
+ if (this.options.metricsEnabled) {
352
+ this.stats.current.missLatencies.push(ms);
353
+ if (this.stats.current.missLatencies.length > this.options.maxLatencies) {
354
+ this.stats.current.missLatencies.shift();
355
+ }
356
+ }
357
+ }
358
+
359
+ recordSetLatency(ms) {
360
+ // Only record latency if main metrics are enabled
361
+ if (this.options.metricsEnabled) {
362
+ this.stats.current.setLatencies.push(ms);
363
+ if (this.stats.current.setLatencies.length > this.options.maxLatencies) {
364
+ this.stats.current.setLatencies.shift();
365
+ }
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Record a delete latency
371
+ * @param {number} ms - the latency to record
372
+ */
373
+ recordDeleteLatency(ms) {
374
+ // Only record latency if main metrics are enabled
375
+ if (this.options.metricsEnabled) {
376
+ this.stats.current.deleteLatencies.push(ms);
377
+ if (this.stats.current.deleteLatencies.length > this.options.maxLatencies) {
378
+ this.stats.current.deleteLatencies.shift();
379
+ }
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Record key access with enhanced latency tracking and context
385
+ * @param {string} key - the key to record
386
+ * @param {string} operation - the operation to record
387
+ * @param {object} metadata - the metadata to record
388
+ */
389
+ recordKeyAccess(key, operation, metadata = {}) {
390
+ this.log.debug(`recordKeyAccess called with key: ${key}, operation: ${operation}, enabled: ${this.options.keyMetricsEnabled}`);
391
+
392
+ if (!key) {
393
+ this.log.warn('recordKeyAccess called with null/undefined key');
394
+ return;
395
+ }
396
+
397
+ if (!this.stats.current.keyAccess.has(key)) {
398
+ this.log.debug(`Creating new key stats for: ${key}`);
399
+ this.stats.current.keyAccess.set(key, {
400
+ key: key,
401
+ // Read-through metrics (with latencies)
402
+ hits: 0,
403
+ misses: 0,
404
+ sets: 0,
405
+ deletes: 0,
406
+ errors: 0,
407
+ totalRequests: 0,
408
+ hitRatio: 0,
409
+ cacheEfficiency: 0,
410
+ // Read-through latency tracking
411
+ hitLatencies: [],
412
+ missLatencies: [],
413
+ setLatencies: [],
414
+ deleteLatencies: [],
415
+ avgHitLatency: 0,
416
+ avgMissLatency: 0,
417
+ avgReadThroughLatency: 0,
418
+ minHitLatency: Infinity,
419
+ maxHitLatency: 0,
420
+ minMissLatency: Infinity,
421
+ maxMissLatency: 0,
422
+ // Read-through performance metrics
423
+ throughput: 0,
424
+ errorRate: 0,
425
+ // Native function metrics (counts only)
426
+ nativeHits: 0,
427
+ nativeMisses: 0,
428
+ nativeSets: 0,
429
+ nativeDeletes: 0,
430
+ nativeClears: 0,
431
+ nativeDeleteByTags: 0,
432
+ nativeErrors: 0,
433
+ totalNativeOperations: 0,
434
+ // Native function performance metrics
435
+ nativeThroughput: 0,
436
+ nativeErrorRate: 0,
437
+ // Operation type tracking
438
+ operationType: metadata.operationType || 'UNKNOWN',
439
+ lastAccess: Date.now(),
440
+ timestamp: Date.now(),
441
+ // Enhanced metadata
442
+ dataType: metadata.dataType || 'custom',
443
+ serviceName: metadata.serviceName || '',
444
+ entityName: metadata.entityName || '',
445
+ operation: metadata.operation || '',
446
+ metadata: metadata.metadata || '',
447
+ // Enhanced context information
448
+ context: metadata.context || '',
449
+ query: metadata.query || '',
450
+ subject: metadata.subject || '',
451
+ target: metadata.target || '',
452
+ tenant: metadata.tenant || '',
453
+ user: metadata.user || '',
454
+ locale: metadata.locale || '',
455
+ cacheOptions: metadata.cacheOptions || ''
456
+ });
457
+ }
458
+
459
+ const keyStats = this.stats.current.keyAccess.get(key);
460
+ keyStats.lastAccess = Date.now();
461
+
462
+ // Handle different operation types
463
+ switch (operation) {
464
+ case 'hit':
465
+ case 'miss':
466
+ case 'set':
467
+ case 'delete':
468
+ // Read-through operations
469
+ const pluralMap = { hit: 'hits', miss: 'misses', set: 'sets', delete: 'deletes' };
470
+ const prop = pluralMap[operation];
471
+ if (prop) keyStats[prop]++;
472
+ // Recalculate totalRequests to ensure accuracy
473
+ keyStats.totalRequests = keyStats.hits + keyStats.misses;
474
+
475
+ this.log.debug(`Read-through operation ${operation} for key ${key}: ${prop}=${keyStats[prop]}, totalRequests=${keyStats.totalRequests}`);
476
+
477
+ // Record latency for read-through operations
478
+ if (metadata.latency !== undefined) {
479
+ this.recordKeyLatency(keyStats, operation, metadata.latency);
480
+ }
481
+ break;
482
+
483
+ case 'nativeHit':
484
+ case 'nativeMiss':
485
+ case 'nativeSet':
486
+ case 'nativeDelete':
487
+ // Native operations (separate from read-through, no double counting)
488
+ // Use a more robust approach to avoid string manipulation issues
489
+ let nativeProperty;
490
+ switch (operation) {
491
+ case 'nativeHit':
492
+ nativeProperty = 'nativeHits';
493
+ break;
494
+ case 'nativeMiss':
495
+ nativeProperty = 'nativeMisses';
496
+ break;
497
+ case 'nativeSet':
498
+ nativeProperty = 'nativeSets';
499
+ break;
500
+ case 'nativeDelete':
501
+ nativeProperty = 'nativeDeletes';
502
+ break;
503
+ default:
504
+ this.log.warn(`Unknown native operation: ${operation}`);
505
+ return;
506
+ }
507
+
508
+ keyStats[nativeProperty]++;
509
+ // Recalculate totalNativeOperations to ensure accuracy
510
+ keyStats.totalNativeOperations = keyStats.nativeHits + keyStats.nativeMisses + keyStats.nativeSets + keyStats.nativeDeletes;
511
+
512
+ this.log.debug(`Native operation ${operation} for key ${key}: ${nativeProperty}=${keyStats[nativeProperty]}, totalNativeOperations=${keyStats.totalNativeOperations}`);
513
+
514
+ break;
515
+
516
+ default:
517
+ this.log.warn(`Unknown operation type: ${operation}`);
518
+ return;
519
+ }
520
+
521
+ // Calculate hit ratio for read-through operations
522
+ const totalReadThrough = keyStats.hits + keyStats.misses;
523
+ if (totalReadThrough > 0) {
524
+ keyStats.hitRatio = (keyStats.hits / totalReadThrough) * 100;
525
+ }
526
+
527
+ // Calculate performance metrics
528
+ const uptimeMs = Date.now() - keyStats.timestamp;
529
+ if (uptimeMs > 0) {
530
+ keyStats.throughput = (keyStats.totalRequests / uptimeMs) * 1000;
531
+ keyStats.nativeThroughput = (keyStats.totalNativeOperations / uptimeMs) * 1000;
532
+ }
533
+
534
+ if (keyStats.totalRequests > 0) {
535
+ keyStats.errorRate = (keyStats.errors / keyStats.totalRequests) * 100;
536
+ }
537
+
538
+ if (keyStats.totalNativeOperations > 0) {
539
+ keyStats.nativeErrorRate = (keyStats.nativeErrors / keyStats.totalNativeOperations) * 100;
540
+ }
541
+
542
+ // Calculate cache efficiency
543
+ if (keyStats.avgHitLatency > 0 && keyStats.avgMissLatency > 0) {
544
+ keyStats.cacheEfficiency = keyStats.avgMissLatency / keyStats.avgHitLatency;
545
+ }
546
+
547
+ // Update context information if provided
548
+ if (metadata.context) {
549
+ keyStats.context = metadata.context;
550
+ }
551
+ if (metadata.query) {
552
+ keyStats.query = metadata.query;
553
+ }
554
+ if (metadata.subject) {
555
+ keyStats.subject = metadata.subject;
556
+ }
557
+ if (metadata.tenant) {
558
+ keyStats.tenant = metadata.tenant;
559
+ }
560
+ if (metadata.user) {
561
+ keyStats.user = metadata.user;
562
+ }
563
+ if (metadata.locale) {
564
+ keyStats.locale = metadata.locale;
565
+ }
566
+ if (metadata.target) {
567
+ keyStats.target = metadata.target;
568
+ }
569
+ if (metadata.cacheOptions) {
570
+ keyStats.cacheOptions = metadata.cacheOptions;
571
+ }
572
+ // Keep only top accessed keys
573
+ if (this.stats.current.keyAccess.size > this.options.maxKeyMetrics) {
574
+ const entries = Array.from(this.stats.current.keyAccess.entries());
575
+ entries.sort((a, b) => {
576
+ const totalA = a[1].totalRequests + a[1].totalNativeOperations;
577
+ const totalB = b[1].totalRequests + b[1].totalNativeOperations;
578
+ return totalB - totalA;
579
+ });
580
+
581
+ this.stats.current.keyAccess = new Map(entries.slice(0, this.options.maxKeyMetrics));
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Record latency for a specific key operation
587
+ * @param {object} keyStats - the key statistics object
588
+ * @param {string} operation - the operation type
589
+ * @param {number} latency - the latency to record
590
+ */
591
+ recordKeyLatency(keyStats, operation, latency) {
592
+ const latencyArray = keyStats[operation + 'Latencies'];
593
+
594
+ if (latencyArray) {
595
+ latencyArray.push(latency);
596
+
597
+ // Keep only recent latencies (last 100)
598
+ if (latencyArray.length > 100) {
599
+ latencyArray.shift();
600
+ }
601
+
602
+ // Calculate statistics for this operation
603
+ const sortedLatencies = [...latencyArray].sort((a, b) => a - b);
604
+ const avgKey = `avg${operation.charAt(0).toUpperCase() + operation.slice(1)}Latency`;
605
+ const minKey = `min${operation.charAt(0).toUpperCase() + operation.slice(1)}Latency`;
606
+ const maxKey = `max${operation.charAt(0).toUpperCase() + operation.slice(1)}Latency`;
607
+
608
+ keyStats[avgKey] = latencyArray.reduce((sum, l) => sum + l, 0) / latencyArray.length;
609
+ keyStats[minKey] = Math.min(keyStats[minKey], latency);
610
+ keyStats[maxKey] = Math.max(keyStats[maxKey], latency);
611
+
612
+ // No percentiles needed - just min/max tracking
613
+ }
614
+ }
615
+
616
+
617
+
618
+ /**
619
+ * Reset current statistics
620
+ */
621
+ resetCurrentStats() {
622
+ this.stats.current = {
623
+ // Read-through metrics (hits and misses only)
624
+ hits: 0,
625
+ misses: 0,
626
+ errors: 0,
627
+ totalRequests: 0,
628
+ latencies: [],
629
+ hitLatencies: [],
630
+ missLatencies: [],
631
+
632
+ // Native function metrics
633
+ nativeSets: 0,
634
+ nativeGets: 0,
635
+ nativeDeletes: 0,
636
+ nativeClears: 0,
637
+ nativeDeleteByTags: 0,
638
+ nativeErrors: 0,
639
+ totalNativeOperations: 0,
640
+
641
+ keyAccess: new Map(),
642
+ startTime: Date.now(),
643
+ lastReset: Date.now()
644
+ };
645
+ this.stats.lastPersisted = Date.now();
646
+ }
647
+
648
+
649
+
650
+ /**
651
+ * Get all tracked keys from database or memory
652
+ * @returns {Array} - array of all tracked keys with enhanced data
653
+ */
654
+ async getAllTrackedKeys() {
655
+ // If key metrics is enabled, try to get from database first
656
+ if (this.options.keyMetricsEnabled) {
657
+ try {
658
+ const dbKeys = await SELECT.from("plugin_cds_caching_KeyMetrics")
659
+ .where({ cache: this.options.cache });
660
+
661
+ if (dbKeys && dbKeys.length > 0) {
662
+ return dbKeys.map(key => ({
663
+ key: key.keyName,
664
+ hits: key.hits,
665
+ misses: key.misses,
666
+ totalRequests: key.totalRequests,
667
+ lastAccess: new Date(key.lastAccess),
668
+ dataType: key.dataType,
669
+ operation: key.operation,
670
+ metadata: key.metadata,
671
+ // Enhanced latency data
672
+ avgHitLatency: key.avgHitLatency,
673
+ avgMissLatency: key.avgMissLatency,
674
+ minHitLatency: key.minHitLatency,
675
+ maxHitLatency: key.maxHitLatency,
676
+ minMissLatency: key.minMissLatency,
677
+ maxMissLatency: key.maxMissLatency,
678
+ // Enhanced context data
679
+ context: key.context,
680
+ query: key.query,
681
+ subject: key.subject,
682
+ target: key.target,
683
+ tenant: key.tenant,
684
+ user: key.user,
685
+ locale: key.locale,
686
+ timestamp: new Date(key.timestamp),
687
+ cacheOptions: key.cacheOptions
688
+ }));
689
+ }
690
+ } catch (error) {
691
+ this.log.warn(`Failed to get all keys from database, falling back to memory: ${error.message}`);
692
+ }
693
+ }
694
+
695
+ // Fallback to memory-based data
696
+ const entries = Array.from(this.stats.current.keyAccess.entries());
697
+ return entries.map(([key, stats]) => ({
698
+ key: key,
699
+ hits: stats.hits,
700
+ misses: stats.misses,
701
+ totalRequests: stats.totalRequests,
702
+ lastAccess: new Date(stats.lastAccess),
703
+ dataType: stats.dataType,
704
+ operation: stats.operation,
705
+ metadata: stats.metadata,
706
+ // Enhanced latency data
707
+ avgHitLatency: stats.avgHitLatency,
708
+ avgMissLatency: stats.avgMissLatency,
709
+ minHitLatency: stats.minHitLatency === Infinity ? 0 : stats.minHitLatency,
710
+ maxHitLatency: stats.maxHitLatency,
711
+ minMissLatency: stats.minMissLatency === Infinity ? 0 : stats.minMissLatency,
712
+ maxMissLatency: stats.maxMissLatency,
713
+ // Enhanced context data
714
+ context: stats.context,
715
+ query: stats.query,
716
+ subject: stats.subject,
717
+ target: stats.target,
718
+ tenant: stats.tenant,
719
+ user: stats.user,
720
+ locale: stats.locale,
721
+ timestamp: new Date(stats.timestamp),
722
+ cacheOptions: stats.cacheOptions
723
+ }));
724
+ }
725
+
726
+ /**
727
+ * Get detailed information for a specific key from database or memory
728
+ * @param {string} keyName - the key to get details for
729
+ * @returns {object|null} - detailed key information or null if not found
730
+ */
731
+ async getKeyDetails(keyName) {
732
+ // If key metrics is enabled, try to get from database first
733
+ if (this.options.keyMetricsEnabled) {
734
+ try {
735
+ const keyDetails = await SELECT.one.from("plugin_cds_caching_KeyMetrics")
736
+ .where({ cache: this.options.cache, keyName: keyName });
737
+
738
+ if (keyDetails) {
739
+ return {
740
+ key: keyDetails.keyName,
741
+ hits: keyDetails.hits,
742
+ misses: keyDetails.misses,
743
+ sets: keyDetails.sets,
744
+ deletes: keyDetails.deletes,
745
+ totalRequests: keyDetails.totalRequests,
746
+ lastAccess: new Date(keyDetails.lastAccess),
747
+ dataType: keyDetails.dataType,
748
+ operation: keyDetails.operation,
749
+ metadata: keyDetails.metadata,
750
+ // Enhanced latency data
751
+ avgHitLatency: keyDetails.avgHitLatency,
752
+ avgMissLatency: keyDetails.avgMissLatency,
753
+ minHitLatency: keyDetails.minHitLatency,
754
+ maxHitLatency: keyDetails.maxHitLatency,
755
+ minMissLatency: keyDetails.minMissLatency,
756
+ maxMissLatency: keyDetails.maxMissLatency,
757
+ // Enhanced context data
758
+ context: keyDetails.context,
759
+ query: keyDetails.query,
760
+ subject: keyDetails.subject,
761
+ target: keyDetails.target,
762
+ tenant: keyDetails.tenant,
763
+ user: keyDetails.user,
764
+ locale: keyDetails.locale,
765
+ timestamp: new Date(keyDetails.timestamp),
766
+ cacheOptions: keyDetails.cacheOptions
767
+ };
768
+ }
769
+ } catch (error) {
770
+ this.log.warn(`Failed to get key details from database, falling back to memory: ${error.message}`);
771
+ }
772
+ }
773
+
774
+ // Fallback to memory-based data
775
+ const keyStats = this.stats.current.keyAccess.get(keyName);
776
+ if (!keyStats) {
777
+ return null;
778
+ }
779
+
780
+ return {
781
+ key: keyName,
782
+ hits: keyStats.hits,
783
+ misses: keyStats.misses,
784
+ totalRequests: keyStats.totalRequests,
785
+ lastAccess: new Date(keyStats.lastAccess),
786
+ dataType: keyStats.dataType,
787
+ operation: keyStats.operation,
788
+ metadata: keyStats.metadata,
789
+ // Enhanced latency data
790
+ avgHitLatency: keyStats.avgHitLatency,
791
+ avgMissLatency: keyStats.avgMissLatency,
792
+ minHitLatency: keyStats.minHitLatency === Infinity ? 0 : keyStats.minHitLatency,
793
+ maxHitLatency: keyStats.maxHitLatency,
794
+ minMissLatency: keyStats.minMissLatency === Infinity ? 0 : keyStats.minMissLatency,
795
+ maxMissLatency: keyStats.maxMissLatency,
796
+ // Enhanced context data
797
+ context: keyStats.context,
798
+ query: keyStats.query,
799
+ subject: keyStats.subject,
800
+ target: keyStats.target,
801
+ tenant: keyStats.tenant,
802
+ user: keyStats.user,
803
+ locale: keyStats.locale,
804
+ timestamp: new Date(keyStats.timestamp),
805
+ cacheOptions: keyStats.cacheOptions
806
+ };
807
+ }
808
+
809
+ /**
810
+ * Persist statistics
811
+ */
812
+ async persistMetrics() {
813
+ // Only persist if at least one type of metrics is enabled
814
+ if (!this.options.metricsEnabled && !this.options.keyMetricsEnabled) {
815
+ this.log.debug(`PersistStats called but all statistics are disabled for cache ${this.options.cache}`);
816
+ return;
817
+ }
818
+
819
+ this.log.debug(`Starting persistStats for cache ${this.options.cache}, enabled: ${this.options.metricsEnabled}`);
820
+
821
+ const now = new Date();
822
+
823
+ // Calculate beginning of hour for hourly stats
824
+ const hourlyTimestamp = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0).toISOString();
825
+
826
+ // Calculate beginning of day for daily stats
827
+ const dailyTimestamp = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0).toISOString();
828
+
829
+ const hourlyId = `hourly:${hourlyTimestamp.slice(0, 13)}`;
830
+ const dailyId = `daily:${dailyTimestamp.slice(0, 10)}`;
831
+
832
+ const stats = await this.calculateStats();
833
+ this.log.debug(`Calculated stats for cache ${this.options.cache}:`);
834
+
835
+ try {
836
+ // Use persistence manager to handle database operations
837
+ await this.persistenceManager.persistHourlyStats(stats, hourlyId, hourlyTimestamp, this.options.metricsEnabled);
838
+ await this.persistenceManager.persistKeyMetrics(this.stats.current.keyAccess, this.options.keyMetricsEnabled);
839
+
840
+ // Reset current stats after successful persistence
841
+ this.resetCurrentStats();
842
+ this.stats.lastPersisted = Date.now();
843
+
844
+ this.log.debug(`Successfully persisted stats for cache ${this.options.cache}`);
845
+ } catch (error) {
846
+ this.log.error(`Failed to persist stats for cache ${this.options.cache}:`, error);
847
+ }
848
+ }
849
+
850
+ async calculateStats() {
851
+ const latencies = this.stats.current.latencies;
852
+ const hitLatencies = this.stats.current.hitLatencies;
853
+ const missLatencies = this.stats.current.missLatencies;
854
+ const totalRequests = this.stats.current.hits + this.stats.current.misses;
855
+ const uptimeMs = Date.now() - this.stats.current.startTime;
856
+
857
+ // Read-through latency calculations
858
+ const avgHitLatency = hitLatencies.length > 0
859
+ ? hitLatencies.reduce((a, b) => a + b, 0) / hitLatencies.length
860
+ : 0;
861
+
862
+ const avgMissLatency = missLatencies.length > 0
863
+ ? missLatencies.reduce((a, b) => a + b, 0) / missLatencies.length
864
+ : 0;
865
+
866
+ let minHitLatency = 0, maxHitLatency = 0;
867
+ let minMissLatency = 0, maxMissLatency = 0;
868
+
869
+ if (hitLatencies.length > 0) {
870
+ const sortedHitLatencies = [...hitLatencies].sort((a, b) => a - b);
871
+ minHitLatency = sortedHitLatencies[0];
872
+ maxHitLatency = sortedHitLatencies[sortedHitLatencies.length - 1];
873
+ }
874
+
875
+ if (missLatencies.length > 0) {
876
+ const sortedMissLatencies = [...missLatencies].sort((a, b) => a - b);
877
+ minMissLatency = sortedMissLatencies[0];
878
+ maxMissLatency = sortedMissLatencies[sortedMissLatencies.length - 1];
879
+ }
880
+
881
+ // Cache efficiency metrics
882
+ const cacheEfficiency = (hitLatencies.length > 0 && missLatencies.length > 0 && avgHitLatency > 0)
883
+ ? avgMissLatency / avgHitLatency // How much faster hits are than misses
884
+ : 0;
885
+
886
+ // Calculate average read-through latency (combined hits and misses)
887
+ const allReadThroughLatencies = [...hitLatencies, ...missLatencies];
888
+ const avgReadThroughLatency = allReadThroughLatencies.length > 0
889
+ ? allReadThroughLatencies.reduce((a, b) => a + b, 0) / allReadThroughLatencies.length
890
+ : 0;
891
+
892
+ // Native function throughput calculation
893
+ const nativeThroughput = uptimeMs > 0 ? (this.stats.current.totalNativeOperations / uptimeMs) * 1000 : 0;
894
+ const nativeErrorRate = this.stats.current.totalNativeOperations > 0
895
+ ? (this.stats.current.nativeErrors / this.stats.current.totalNativeOperations) * 100
896
+ : 0;
897
+
898
+ return {
899
+ // Read-through metrics (hits and misses only)
900
+ hits: this.stats.current.hits,
901
+ misses: this.stats.current.misses,
902
+ errors: this.stats.current.errors,
903
+ totalRequests,
904
+
905
+ // Read-through latency metrics (hits and misses only)
906
+ avgHitLatency,
907
+ minHitLatency,
908
+ maxHitLatency,
909
+ avgMissLatency,
910
+ minMissLatency,
911
+ maxMissLatency,
912
+ avgReadThroughLatency,
913
+
914
+ // Read-through performance metrics
915
+ hitRatio: totalRequests > 0 ? this.stats.current.hits / totalRequests : 0,
916
+ throughput: uptimeMs > 0 ? (totalRequests / uptimeMs) * 1000 : 0, // requests per second
917
+ errorRate: totalRequests > 0 ? this.stats.current.errors / totalRequests : 0,
918
+ cacheEfficiency,
919
+
920
+ // Native function metrics (basic counts only)
921
+ nativeSets: this.stats.current.nativeSets,
922
+ nativeGets: this.stats.current.nativeGets,
923
+ nativeDeletes: this.stats.current.nativeDeletes,
924
+ nativeClears: this.stats.current.nativeClears,
925
+ nativeDeleteByTags: this.stats.current.nativeDeleteByTags,
926
+ nativeErrors: this.stats.current.nativeErrors,
927
+ totalNativeOperations: this.stats.current.totalNativeOperations,
928
+
929
+ // Native function performance metrics
930
+ nativeThroughput,
931
+ nativeErrorRate,
932
+
933
+ // Common metrics
934
+ memoryUsage: process.memoryUsage().heapUsed,
935
+ itemCount: await this.options.getItemCount?.() || 0,
936
+ uptimeMs
937
+ };
938
+ }
939
+
940
+ async getMetrics(period = 'hourly', from, to) {
941
+ if (!this.options.metricsEnabled) return null;
942
+
943
+ const query = SELECT.from("plugin_cds_caching_Metrics")
944
+ .where({ period: period });
945
+
946
+ if (from) query.and({ timestamp: { '>=': from.toISOString() } });
947
+ if (to) query.and({ timestamp: { '<=': to.toISOString() } });
948
+
949
+ query.orderBy({ timestamp: 'desc' });
950
+
951
+ return await query;
952
+ }
953
+
954
+ async getKeyMetrics(key, from, to) {
955
+ if (!this.options.keyMetricsEnabled) return null;
956
+ const query = SELECT.from("plugin_cds_caching_KeyMetrics")
957
+ .where({ cache: this.options.cache, keyName: key });
958
+
959
+ if (from) query.and({ timestamp: { '>=': from.toISOString() } });
960
+ if (to) query.and({ timestamp: { '<=': to.toISOString() } });
961
+
962
+ query.orderBy({ timestamp: 'desc' });
963
+
964
+ return await query;
965
+ }
966
+
967
+ async getCurrentKeyMetrics() {
968
+ if (!this.options.keyMetricsEnabled) return null;
969
+ return this.stats.current.keyAccess;
970
+ }
971
+
972
+ async getCurrentStats() {
973
+ if (!this.options.metricsEnabled) return null;
974
+
975
+ const { current, lastPersisted } = this.stats;
976
+ const hitLatencies = current.hitLatencies;
977
+ const missLatencies = current.missLatencies;
978
+ const totalRequests = current.hits + current.misses;
979
+ const uptimeMs = Date.now() - current.startTime;
980
+
981
+ // Cache-through latency calculations
982
+ const avgHitLatency = hitLatencies.length > 0
983
+ ? hitLatencies.reduce((a, b) => a + b, 0) / hitLatencies.length
984
+ : 0;
985
+
986
+ const avgMissLatency = missLatencies.length > 0
987
+ ? missLatencies.reduce((a, b) => a + b, 0) / missLatencies.length
988
+ : 0;
989
+
990
+ let minHitLatency = 0, maxHitLatency = 0;
991
+ let minMissLatency = 0, maxMissLatency = 0;
992
+
993
+ if (hitLatencies.length > 0) {
994
+ const sortedHitLatencies = [...hitLatencies].sort((a, b) => a - b);
995
+ minHitLatency = sortedHitLatencies[0];
996
+ maxHitLatency = sortedHitLatencies[sortedHitLatencies.length - 1];
997
+ }
998
+
999
+ if (missLatencies.length > 0) {
1000
+ const sortedMissLatencies = [...missLatencies].sort((a, b) => a - b);
1001
+ minMissLatency = sortedMissLatencies[0];
1002
+ maxMissLatency = sortedMissLatencies[sortedMissLatencies.length - 1];
1003
+ }
1004
+
1005
+ // Cache efficiency metrics
1006
+ const cacheEfficiency = (hitLatencies.length > 0 && missLatencies.length > 0 && avgHitLatency > 0)
1007
+ ? avgMissLatency / avgHitLatency // How much faster hits are than misses
1008
+ : 0;
1009
+
1010
+ // Calculate average read-through latency (combined hits and misses)
1011
+ const allReadThroughLatencies = [...hitLatencies, ...missLatencies];
1012
+ const avgReadThroughLatency = allReadThroughLatencies.length > 0
1013
+ ? allReadThroughLatencies.reduce((a, b) => a + b, 0) / allReadThroughLatencies.length
1014
+ : 0;
1015
+
1016
+ // Native function performance metrics
1017
+ const nativeThroughput = uptimeMs > 0 ? (current.totalNativeOperations / uptimeMs) * 1000 : 0;
1018
+ const nativeErrorRate = current.totalNativeOperations > 0
1019
+ ? (current.nativeErrors / current.totalNativeOperations) * 100
1020
+ : 0;
1021
+
1022
+ return {
1023
+ // Read-through metrics (hits and misses only - sets/deletes are redundant)
1024
+ hits: current.hits,
1025
+ misses: current.misses,
1026
+ errors: current.errors,
1027
+ totalRequests,
1028
+
1029
+ // Read-through latency metrics (hits and misses only)
1030
+ avgHitLatency,
1031
+ minHitLatency,
1032
+ maxHitLatency,
1033
+ avgMissLatency,
1034
+ minMissLatency,
1035
+ maxMissLatency,
1036
+ avgReadThroughLatency,
1037
+
1038
+ // Read-through performance metrics
1039
+ hitRatio: totalRequests > 0 ? current.hits / totalRequests : 0,
1040
+ throughput: uptimeMs > 0 ? (totalRequests / uptimeMs) * 1000 : 0,
1041
+ errorRate: totalRequests > 0 ? current.errors / totalRequests : 0,
1042
+ cacheEfficiency,
1043
+
1044
+ // Native function metrics
1045
+ nativeSets: current.nativeSets,
1046
+ nativeGets: current.nativeGets,
1047
+ nativeDeletes: current.nativeDeletes,
1048
+ nativeClears: current.nativeClears,
1049
+ nativeDeleteByTags: current.nativeDeleteByTags,
1050
+ nativeErrors: current.nativeErrors,
1051
+ totalNativeOperations: current.totalNativeOperations,
1052
+
1053
+ // Native function performance metrics
1054
+ nativeThroughput,
1055
+ nativeErrorRate,
1056
+
1057
+ // Common metrics
1058
+ memoryUsage: process.memoryUsage().heapUsed,
1059
+ itemCount: await this.options.getItemCount?.() || 0,
1060
+ uptimeMs
1061
+ };
1062
+ }
1063
+
1064
+ dispose() {
1065
+ if (this.persistInterval) {
1066
+ clearInterval(this.persistInterval);
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Manually trigger persistence (for testing and debugging)
1072
+ */
1073
+ async triggerPersistence() {
1074
+ this.log.info(`Manually triggering persistence for cache ${this.options.cache}, enabled: ${this.options.metricsEnabled}, keyMetrics: ${this.options.keyMetricsEnabled}`);
1075
+ if (this.options.metricsEnabled || this.options.keyMetricsEnabled) {
1076
+ await this.persistMetrics();
1077
+ } else {
1078
+ this.log.info(`Persistence skipped - all statistics are disabled for cache ${this.options.cache}`);
1079
+ }
1080
+ }
1081
+
1082
+ /**
1083
+ * Get current persistence interval status
1084
+ */
1085
+ getPersistenceStatus() {
1086
+ return {
1087
+ metricsEnabled: this.options.metricsEnabled,
1088
+ keyMetricsEnabled: this.options.keyMetricsEnabled,
1089
+ intervalExists: this.persistInterval !== null,
1090
+ lastPersisted: this.stats.lastPersisted,
1091
+ persistenceInterval: this.options.persistenceInterval
1092
+ };
1093
+ }
1094
+
1095
+ async clearMetrics() {
1096
+ await this.persistenceManager.deleteMetrics();
1097
+ // Also reset the current stats
1098
+ this.resetCurrentStats();
1099
+ }
1100
+
1101
+ async clearKeyMetrics() {
1102
+ await this.persistenceManager.deleteKeyMetrics();
1103
+ // Also reset the current stats
1104
+ this.resetCurrentStats();
1105
+ }
1106
+
1107
+ /**
1108
+ * Delete metrics (alias for clearMetrics for compatibility)
1109
+ * @returns {Promise<void>}
1110
+ */
1111
+ async deleteMetrics() {
1112
+ return this.clearMetrics();
1113
+ }
1114
+
1115
+ /**
1116
+ * Delete key metrics (alias for clearKeyMetrics for compatibility)
1117
+ * @returns {Promise<void>}
1118
+ */
1119
+ async deleteKeyMetrics() {
1120
+ return this.clearKeyMetrics();
1121
+ }
1122
+ }
1123
+
1124
+ module.exports = CacheStatisticsHandler;