Skip to main content

stratum_apps/monitoring/
snapshot_cache.rs

1//! Snapshot cache for monitoring data
2//!
3//! This module provides a cache layer that decouples monitoring API requests
4//! from the business logic locks (e.g., `ChannelManagerData`).
5//!
6//! ## Problem
7//!
8//! Without caching, every monitoring request acquires the same lock used by
9//! share validation and job distribution. An attacker can spam monitoring
10//! endpoints to cause lock contention, degrading mining performance.
11//!
12//! ## Solution
13//!
14//! The `SnapshotCache` periodically copies monitoring data from the source
15//! (via the monitoring traits) into a cache. API requests read from the cache
16//! without acquiring the business logic lock.
17//!
18//! ```text
19//! Business Logic                    Monitoring
20//! ──────────────                    ──────────
21//!     │                                  │
22//!     │ (holds lock for                  │
23//!     │  share validation)               │
24//!     │                                  │
25//!     └──────────────────────────────────┤
26//!                                        │
27//!                              ┌─────────▼─────────┐
28//!                              │  SnapshotCache    │
29//!                              │  (RwLock, fast)   │
30//!                              └─────────┬─────────┘
31//!                                        │
32//!                    ┌───────────────────┼───────────────────┐
33//!                    │                   │                   │
34//!              ┌─────▼─────┐       ┌─────▼─────┐       ┌─────▼─────┐
35//!              │ /metrics  │       │ /api/v1/* │       │ /health   │
36//!              └───────────┘       └───────────┘       └───────────┘
37//! ```
38
39use std::{
40    collections::HashSet,
41    sync::{Arc, Mutex, RwLock},
42    time::{Duration, Instant},
43};
44
45use stratum_core::mining_sv2::{
46    ERROR_CODE_SUBMIT_SHARES_BAD_EXTRANONCE_SIZE, ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW,
47    ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE, ERROR_CODE_SUBMIT_SHARES_INVALID_CHANNEL_ID,
48    ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID, ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE,
49    ERROR_CODE_SUBMIT_SHARES_STALE_SHARE,
50};
51use tracing::debug;
52
53use super::{
54    client::{Sv2ClientInfo, Sv2ClientsMonitoring, Sv2ClientsSummary},
55    prometheus_metrics::PrometheusMetrics,
56    server::{ServerInfo, ServerMonitoring, ServerSummary},
57    sv1::{Sv1ClientInfo, Sv1ClientsMonitoring, Sv1ClientsSummary},
58};
59
60/// All `SubmitSharesError.error_code` values defined by `mining_sv2`.
61///
62/// Pre-seeding these labels with `0` on every refresh ensures the
63/// `sv2_*_shares_rejected_total` GaugeVec emits its series even before
64/// the first rejection — so Grafana panels and alerting rules that
65/// reference the metric never see "no data".
66const SHARE_REJECTION_CODES: &[&str] = &[
67    ERROR_CODE_SUBMIT_SHARES_INVALID_CHANNEL_ID,
68    ERROR_CODE_SUBMIT_SHARES_INVALID_SHARE,
69    ERROR_CODE_SUBMIT_SHARES_STALE_SHARE,
70    ERROR_CODE_SUBMIT_SHARES_INVALID_JOB_ID,
71    ERROR_CODE_SUBMIT_SHARES_DIFFICULTY_TOO_LOW,
72    ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE,
73    ERROR_CODE_SUBMIT_SHARES_BAD_EXTRANONCE_SIZE,
74];
75
76/// Tracks which label combinations were set on the previous refresh so we can
77/// remove only stale series instead of calling `.reset()` (which would create a
78/// gap where all label series momentarily disappear).
79#[derive(Default)]
80struct PreviousPrometheusLabelSets {
81    /// Labels for server per-channel GaugeVecs: [channel_id, user_identity]
82    server_channel_labels: HashSet<[String; 2]>,
83    /// Labels for server per-rejection GaugeVecs: [channel_id, user_identity, error_code]
84    server_rejected_share_labels: HashSet<[String; 3]>,
85    /// Labels for client per-channel GaugeVecs: [client_id, channel_id, user_identity]
86    client_channel_labels: HashSet<[String; 3]>,
87    /// Labels for client per-rejection GaugeVecs: [client_id, channel_id, user_identity,
88    /// error_code]
89    client_rejected_share_labels: HashSet<[String; 4]>,
90}
91
92/// Cached snapshot of monitoring data.
93///
94/// This struct holds a point-in-time copy of all monitoring data,
95/// allowing API requests to read without acquiring business logic locks.
96#[derive(Debug, Clone, Default)]
97pub struct MonitoringSnapshot {
98    pub timestamp: Option<Instant>,
99    pub server_info: Option<ServerInfo>,
100    pub server_summary: Option<ServerSummary>,
101    pub sv2_clients: Option<Vec<Sv2ClientInfo>>,
102    pub sv2_clients_summary: Option<Sv2ClientsSummary>,
103    pub sv1_clients: Option<Vec<Sv1ClientInfo>>,
104    pub sv1_clients_summary: Option<Sv1ClientsSummary>,
105}
106
107/// A cache that holds monitoring snapshots and refreshes them periodically.
108///
109/// When `PrometheusMetrics` are attached, the cache also updates Prometheus
110/// gauges during each refresh, keeping metric values in lockstep with the
111/// snapshot data. This means the `/metrics` handler never needs to compute
112/// values — it only gathers and encodes.
113pub struct SnapshotCache {
114    snapshot: RwLock<MonitoringSnapshot>,
115    refresh_interval: Duration,
116    server_source: Option<Arc<dyn ServerMonitoring + Send + Sync>>,
117    sv2_clients_source: Option<Arc<dyn Sv2ClientsMonitoring + Send + Sync>>,
118    sv1_clients_source: Option<Arc<dyn Sv1ClientsMonitoring + Send + Sync>>,
119    metrics: Option<PrometheusMetrics>,
120    previous_metrics_labels: Mutex<PreviousPrometheusLabelSets>,
121}
122
123impl Clone for SnapshotCache {
124    fn clone(&self) -> Self {
125        // Clone creates a new cache with the same sources and current snapshot.
126        // previous_metrics_labels is cloned so the new cache can correctly detect
127        // stale label combinations on its first refresh.
128        let current_snapshot = self.snapshot.read().unwrap().clone();
129        // Recovering from a poisoned mutex is safe here: the inner sets only
130        // track which Prometheus label combinations were populated last refresh,
131        // used solely to compute stale-label removals. The data has no
132        // cross-field invariants, and worst-case drift (a stale label surviving
133        // one cycle, or an idempotent remove that we already log at debug) is
134        // harmless. Panicking here would crash the monitoring server.
135        let previous_metrics_labels = self
136            .previous_metrics_labels
137            .lock()
138            .unwrap_or_else(|e| e.into_inner());
139        Self {
140            snapshot: RwLock::new(current_snapshot),
141            refresh_interval: self.refresh_interval,
142            server_source: self.server_source.clone(),
143            sv2_clients_source: self.sv2_clients_source.clone(),
144            sv1_clients_source: self.sv1_clients_source.clone(),
145            metrics: self.metrics.clone(),
146            previous_metrics_labels: Mutex::new(PreviousPrometheusLabelSets {
147                server_channel_labels: previous_metrics_labels.server_channel_labels.clone(),
148                server_rejected_share_labels: previous_metrics_labels
149                    .server_rejected_share_labels
150                    .clone(),
151                client_channel_labels: previous_metrics_labels.client_channel_labels.clone(),
152                client_rejected_share_labels: previous_metrics_labels
153                    .client_rejected_share_labels
154                    .clone(),
155            }),
156        }
157    }
158}
159
160impl SnapshotCache {
161    /// Create a new snapshot cache with the given refresh interval.
162    ///
163    /// # Arguments
164    ///
165    /// * `refresh_interval` - How often to refresh the cache (e.g., 15 seconds)
166    /// * `server_source` - Optional server monitoring trait object
167    /// * `sv2_clients_source` - Optional Sv2 clients monitoring trait object
168    pub fn new(
169        refresh_interval: Duration,
170        server_source: Option<Arc<dyn ServerMonitoring + Send + Sync>>,
171        sv2_clients_source: Option<Arc<dyn Sv2ClientsMonitoring + Send + Sync>>,
172    ) -> Self {
173        Self {
174            snapshot: RwLock::new(MonitoringSnapshot::default()),
175            refresh_interval,
176            server_source,
177            sv2_clients_source,
178            sv1_clients_source: None,
179            metrics: None,
180            previous_metrics_labels: Mutex::new(PreviousPrometheusLabelSets::default()),
181        }
182    }
183
184    /// Add SV1 monitoring source (for Tproxy)
185    pub fn with_sv1_clients_source(
186        mut self,
187        sv1_source: Arc<dyn Sv1ClientsMonitoring + Send + Sync>,
188    ) -> Self {
189        self.sv1_clients_source = Some(sv1_source);
190        self
191    }
192
193    /// Attach (or replace) Prometheus metrics so they are updated during each `refresh()`.
194    ///
195    /// This is called once in `MonitoringServer::new` and may be called again in
196    /// `with_sv1_monitoring` which re-creates the metrics with SV1 gauges enabled.
197    pub fn with_metrics(mut self, metrics: PrometheusMetrics) -> Self {
198        self.metrics = Some(metrics);
199        self
200    }
201
202    /// Get the current snapshot.
203    ///
204    /// This is a fast read that does NOT acquire any business logic locks.
205    /// The returned snapshot may be up to `refresh_interval` old.
206    pub fn get_snapshot(&self) -> MonitoringSnapshot {
207        self.snapshot.read().unwrap().clone()
208    }
209
210    /// Refresh the cache by reading from the data sources.
211    ///
212    /// This method DOES acquire the business logic locks (via the trait methods),
213    /// but it's only called periodically by a background task, not on every request.
214    ///
215    /// When Prometheus metrics are attached, they are updated atomically alongside
216    /// the snapshot — eliminating any gap where metrics could be missing or stale
217    /// relative to the snapshot data.
218    pub fn refresh(&self) {
219        let mut new_snapshot = MonitoringSnapshot {
220            timestamp: Some(Instant::now()),
221            ..Default::default()
222        };
223
224        // Collect server data
225        if let Some(ref source) = self.server_source {
226            new_snapshot.server_info = Some(source.get_server());
227            new_snapshot.server_summary = Some(source.get_server_summary());
228        }
229
230        // Collect Sv2 clients data
231        if let Some(ref source) = self.sv2_clients_source {
232            new_snapshot.sv2_clients = Some(source.get_sv2_clients());
233            new_snapshot.sv2_clients_summary = Some(source.get_sv2_clients_summary());
234        }
235
236        // Collect Sv1 clients data
237        if let Some(ref source) = self.sv1_clients_source {
238            new_snapshot.sv1_clients = Some(source.get_sv1_clients());
239            new_snapshot.sv1_clients_summary = Some(source.get_sv1_clients_summary());
240        }
241
242        // Update Prometheus gauges from the new snapshot data
243        if let Some(ref metrics) = self.metrics {
244            self.update_metrics(metrics, &new_snapshot);
245        }
246
247        // Update the cache
248        *self.snapshot.write().unwrap() = new_snapshot;
249    }
250
251    /// Update all Prometheus gauges from the given snapshot, then remove stale
252    /// label combinations that are no longer present.
253    fn update_metrics(&self, metrics: &PrometheusMetrics, snapshot: &MonitoringSnapshot) {
254        let mut current_server_labels: HashSet<[String; 2]> = HashSet::new();
255        let mut current_server_rejected_labels: HashSet<[String; 3]> = HashSet::new();
256        let mut current_client_labels: HashSet<[String; 3]> = HashSet::new();
257        let mut current_client_rejected_labels: HashSet<[String; 4]> = HashSet::new();
258
259        // Server metrics
260        if let Some(ref summary) = snapshot.server_summary {
261            if let Some(ref m) = metrics.sv2_server_channels {
262                m.with_label_values(&["extended"])
263                    .set(summary.extended_channels as f64);
264                m.with_label_values(&["standard"])
265                    .set(summary.standard_channels as f64);
266            }
267            if let Some(ref m) = metrics.sv2_server_hashrate_total {
268                m.set(summary.total_hashrate as f64);
269            }
270        }
271
272        if let Some(ref server) = snapshot.server_info {
273            for channel in &server.extended_channels {
274                let channel_id = channel.channel_id.to_string();
275                let user = &channel.user_identity;
276                let labels = [channel_id.clone(), user.clone()];
277
278                if let Some(ref m) = metrics.sv2_server_shares_accepted_total {
279                    m.with_label_values(&[&channel_id, user])
280                        .set(channel.shares_acknowledged as f64);
281                }
282                if let Some(ref m) = metrics.sv2_server_shares_rejected_total {
283                    // Pre-seed spec-defined rejection codes so the metric is always emitted
284                    // (avoids GaugeVec lazy-loading hiding the series until first rejection).
285                    for &reason in SHARE_REJECTION_CODES {
286                        m.with_label_values(&[&channel_id, user, reason]).set(0.0);
287                        current_server_rejected_labels.insert([
288                            channel_id.clone(),
289                            user.clone(),
290                            reason.to_string(),
291                        ]);
292                    }
293                    for (error_code, count) in &channel.shares_rejected_by_reason {
294                        m.with_label_values(&[&channel_id, user, error_code])
295                            .set(*count as f64);
296                        current_server_rejected_labels.insert([
297                            channel_id.clone(),
298                            user.clone(),
299                            error_code.clone(),
300                        ]);
301                    }
302                }
303                if let (Some(ref m), Some(hashrate)) = (
304                    &metrics.sv2_server_channel_hashrate,
305                    channel.nominal_hashrate,
306                ) {
307                    m.with_label_values(&[&channel_id, user])
308                        .set(hashrate as f64);
309                }
310                current_server_labels.insert(labels);
311            }
312
313            for channel in &server.standard_channels {
314                let channel_id = channel.channel_id.to_string();
315                let user = &channel.user_identity;
316                let labels = [channel_id.clone(), user.clone()];
317
318                if let Some(ref m) = metrics.sv2_server_shares_accepted_total {
319                    m.with_label_values(&[&channel_id, user])
320                        .set(channel.shares_acknowledged as f64);
321                }
322                if let Some(ref m) = metrics.sv2_server_shares_rejected_total {
323                    // Pre-seed spec-defined rejection codes so the metric is always emitted
324                    // (avoids GaugeVec lazy-loading hiding the series until first rejection).
325                    for &reason in SHARE_REJECTION_CODES {
326                        m.with_label_values(&[&channel_id, user, reason]).set(0.0);
327                        current_server_rejected_labels.insert([
328                            channel_id.clone(),
329                            user.clone(),
330                            reason.to_string(),
331                        ]);
332                    }
333                    for (error_code, count) in &channel.shares_rejected_by_reason {
334                        m.with_label_values(&[&channel_id, user, error_code])
335                            .set(*count as f64);
336                        current_server_rejected_labels.insert([
337                            channel_id.clone(),
338                            user.clone(),
339                            error_code.clone(),
340                        ]);
341                    }
342                }
343                if let (Some(ref m), Some(hashrate)) = (
344                    &metrics.sv2_server_channel_hashrate,
345                    channel.nominal_hashrate,
346                ) {
347                    m.with_label_values(&[&channel_id, user])
348                        .set(hashrate as f64);
349                }
350                current_server_labels.insert(labels);
351            }
352
353            if let Some(ref m) = metrics.sv2_server_blocks_found_total {
354                let total: u64 = server
355                    .extended_channels
356                    .iter()
357                    .map(|c| c.blocks_found as u64)
358                    .chain(
359                        server
360                            .standard_channels
361                            .iter()
362                            .map(|c| c.blocks_found as u64),
363                    )
364                    .sum();
365                m.set(total as f64);
366            }
367        }
368
369        // Sv2 clients metrics
370        if let Some(ref summary) = snapshot.sv2_clients_summary {
371            if let Some(ref m) = metrics.sv2_clients_total {
372                m.set(summary.total_clients as f64);
373            }
374            if let Some(ref m) = metrics.sv2_client_channels {
375                m.with_label_values(&["extended"])
376                    .set(summary.extended_channels as f64);
377                m.with_label_values(&["standard"])
378                    .set(summary.standard_channels as f64);
379            }
380            if let Some(ref m) = metrics.sv2_client_hashrate_total {
381                m.set(summary.total_hashrate as f64);
382            }
383
384            let mut client_blocks_total: u64 = 0;
385
386            for client in snapshot.sv2_clients.as_deref().unwrap_or(&[]) {
387                let client_id = client.client_id.to_string();
388
389                for channel in &client.extended_channels {
390                    let channel_id = channel.channel_id.to_string();
391                    let user = &channel.user_identity;
392                    let labels = [client_id.clone(), channel_id.clone(), user.clone()];
393
394                    if let Some(ref m) = metrics.sv2_client_shares_accepted_total {
395                        m.with_label_values(&[&client_id, &channel_id, user])
396                            .set(channel.shares_accepted as f64);
397                    }
398                    if let Some(ref m) = metrics.sv2_client_shares_rejected_total {
399                        // Pre-seed spec-defined rejection codes so the metric is always emitted
400                        // (avoids GaugeVec lazy-loading hiding the series until first rejection).
401                        for &reason in SHARE_REJECTION_CODES {
402                            m.with_label_values(&[&client_id, &channel_id, user, reason])
403                                .set(0.0);
404                            current_client_rejected_labels.insert([
405                                client_id.clone(),
406                                channel_id.clone(),
407                                user.clone(),
408                                reason.to_string(),
409                            ]);
410                        }
411                        for (error_code, count) in &channel.shares_rejected_by_reason {
412                            m.with_label_values(&[&client_id, &channel_id, user, error_code])
413                                .set(*count as f64);
414                            current_client_rejected_labels.insert([
415                                client_id.clone(),
416                                channel_id.clone(),
417                                user.clone(),
418                                error_code.clone(),
419                            ]);
420                        }
421                    }
422                    if let Some(ref m) = metrics.sv2_client_channel_hashrate {
423                        m.with_label_values(&[&client_id, &channel_id, user])
424                            .set(channel.nominal_hashrate as f64);
425                    }
426                    current_client_labels.insert(labels);
427                    client_blocks_total += channel.blocks_found as u64;
428                }
429
430                for channel in &client.standard_channels {
431                    let channel_id = channel.channel_id.to_string();
432                    let user = &channel.user_identity;
433                    let labels = [client_id.clone(), channel_id.clone(), user.clone()];
434
435                    if let Some(ref m) = metrics.sv2_client_shares_accepted_total {
436                        m.with_label_values(&[&client_id, &channel_id, user])
437                            .set(channel.shares_accepted as f64);
438                    }
439                    if let Some(ref m) = metrics.sv2_client_shares_rejected_total {
440                        // Pre-seed spec-defined rejection codes so the metric is always emitted
441                        // (avoids GaugeVec lazy-loading hiding the series until first rejection).
442                        for &reason in SHARE_REJECTION_CODES {
443                            m.with_label_values(&[&client_id, &channel_id, user, reason])
444                                .set(0.0);
445                            current_client_rejected_labels.insert([
446                                client_id.clone(),
447                                channel_id.clone(),
448                                user.clone(),
449                                reason.to_string(),
450                            ]);
451                        }
452                        for (error_code, count) in &channel.shares_rejected_by_reason {
453                            m.with_label_values(&[&client_id, &channel_id, user, error_code])
454                                .set(*count as f64);
455                            current_client_rejected_labels.insert([
456                                client_id.clone(),
457                                channel_id.clone(),
458                                user.clone(),
459                                error_code.clone(),
460                            ]);
461                        }
462                    }
463                    if let Some(ref m) = metrics.sv2_client_channel_hashrate {
464                        m.with_label_values(&[&client_id, &channel_id, user])
465                            .set(channel.nominal_hashrate as f64);
466                    }
467                    current_client_labels.insert(labels);
468                    client_blocks_total += channel.blocks_found as u64;
469                }
470            }
471
472            if let Some(ref m) = metrics.sv2_client_blocks_found_total {
473                m.set(client_blocks_total as f64);
474            }
475        }
476
477        // SV1 client metrics
478        if let Some(ref summary) = snapshot.sv1_clients_summary {
479            if let Some(ref m) = metrics.sv1_clients_total {
480                m.set(summary.total_clients as f64);
481            }
482            if let Some(ref m) = metrics.sv1_hashrate_total {
483                m.set(summary.total_hashrate as f64);
484            }
485        }
486
487        // Remove stale label combinations that are no longer in the snapshot
488        let mut previous_metrics_labels = self
489            .previous_metrics_labels
490            .lock()
491            .unwrap_or_else(|e| e.into_inner());
492
493        for stale in previous_metrics_labels
494            .server_channel_labels
495            .difference(&current_server_labels)
496        {
497            let label_refs: Vec<&str> = stale.iter().map(|s| s.as_str()).collect();
498            if let Some(ref m) = metrics.sv2_server_shares_accepted_total {
499                if let Err(e) = m.remove_label_values(&label_refs) {
500                    debug!(labels = ?label_refs, error = %e, "failed to remove stale server shares label");
501                }
502            }
503            if let Some(ref m) = metrics.sv2_server_channel_hashrate {
504                if let Err(e) = m.remove_label_values(&label_refs) {
505                    debug!(labels = ?label_refs, error = %e, "failed to remove stale server hashrate label");
506                }
507            }
508        }
509
510        for stale in previous_metrics_labels
511            .server_rejected_share_labels
512            .difference(&current_server_rejected_labels)
513        {
514            let label_refs: Vec<&str> = stale.iter().map(|s| s.as_str()).collect();
515            if let Some(ref m) = metrics.sv2_server_shares_rejected_total {
516                if let Err(e) = m.remove_label_values(&label_refs) {
517                    debug!(labels = ?label_refs, error = %e, "failed to remove stale server rejected shares label");
518                }
519            }
520        }
521
522        for stale in previous_metrics_labels
523            .client_channel_labels
524            .difference(&current_client_labels)
525        {
526            let label_refs: Vec<&str> = stale.iter().map(|s| s.as_str()).collect();
527            if let Some(ref m) = metrics.sv2_client_shares_accepted_total {
528                if let Err(e) = m.remove_label_values(&label_refs) {
529                    debug!(labels = ?label_refs, error = %e, "failed to remove stale client shares label");
530                }
531            }
532            if let Some(ref m) = metrics.sv2_client_channel_hashrate {
533                if let Err(e) = m.remove_label_values(&label_refs) {
534                    debug!(labels = ?label_refs, error = %e, "failed to remove stale client hashrate label");
535                }
536            }
537        }
538
539        for stale in previous_metrics_labels
540            .client_rejected_share_labels
541            .difference(&current_client_rejected_labels)
542        {
543            let label_refs: Vec<&str> = stale.iter().map(|s| s.as_str()).collect();
544            if let Some(ref m) = metrics.sv2_client_shares_rejected_total {
545                if let Err(e) = m.remove_label_values(&label_refs) {
546                    debug!(labels = ?label_refs, error = %e, "failed to remove stale client rejected shares label");
547                }
548            }
549        }
550
551        previous_metrics_labels.server_channel_labels = current_server_labels;
552        previous_metrics_labels.server_rejected_share_labels = current_server_rejected_labels;
553        previous_metrics_labels.client_channel_labels = current_client_labels;
554        previous_metrics_labels.client_rejected_share_labels = current_client_rejected_labels;
555    }
556
557    /// Get the refresh interval
558    pub fn refresh_interval(&self) -> Duration {
559        self.refresh_interval
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    struct MockServerMonitoring;
568    impl ServerMonitoring for MockServerMonitoring {
569        fn get_server(&self) -> ServerInfo {
570            ServerInfo {
571                extended_channels: vec![],
572                standard_channels: vec![],
573            }
574        }
575    }
576
577    struct MockSv2ClientsMonitoring;
578    impl Sv2ClientsMonitoring for MockSv2ClientsMonitoring {
579        fn get_sv2_clients(&self) -> Vec<Sv2ClientInfo> {
580            vec![]
581        }
582    }
583
584    #[test]
585    fn test_snapshot_cache_creation() {
586        let cache = SnapshotCache::new(
587            Duration::from_secs(5),
588            Some(Arc::new(MockServerMonitoring)),
589            Some(Arc::new(MockSv2ClientsMonitoring)),
590        );
591
592        // Before refresh, snapshot has no timestamp
593        let snapshot = cache.get_snapshot();
594        assert!(snapshot.timestamp.is_none());
595        assert_eq!(cache.refresh_interval(), Duration::from_secs(5));
596    }
597
598    #[test]
599    fn test_snapshot_refresh() {
600        let cache = SnapshotCache::new(
601            Duration::from_secs(5),
602            Some(Arc::new(MockServerMonitoring)),
603            Some(Arc::new(MockSv2ClientsMonitoring)),
604        );
605
606        // Before refresh, snapshot has no data
607        let snapshot = cache.get_snapshot();
608        assert!(snapshot.timestamp.is_none());
609        assert!(snapshot.server_info.is_none());
610
611        // After refresh, snapshot has data
612        cache.refresh();
613        let snapshot = cache.get_snapshot();
614        assert!(snapshot.timestamp.is_some());
615        assert!(snapshot.server_info.is_some());
616        assert!(snapshot.sv2_clients.is_some());
617        assert!(snapshot.sv2_clients_summary.is_some());
618    }
619
620    /// Mock monitoring that simulates lock contention with business logic.
621    ///
622    /// This is used to verify that the snapshot cache eliminates lock contention
623    /// between monitoring API requests and business logic operations.
624    struct ContendedMonitoring {
625        lock_hold_duration: Duration,
626        monitoring_lock_acquisitions: std::sync::atomic::AtomicU64,
627        business_lock: std::sync::Mutex<()>,
628    }
629
630    impl ContendedMonitoring {
631        fn new(lock_hold_duration: Duration) -> Self {
632            Self {
633                lock_hold_duration,
634                monitoring_lock_acquisitions: std::sync::atomic::AtomicU64::new(0),
635                business_lock: std::sync::Mutex::new(()),
636            }
637        }
638
639        fn simulate_business_logic(&self) {
640            let _guard = self.business_lock.lock().unwrap();
641            std::thread::sleep(self.lock_hold_duration);
642        }
643
644        fn get_monitoring_acquisitions(&self) -> u64 {
645            self.monitoring_lock_acquisitions
646                .load(std::sync::atomic::Ordering::SeqCst)
647        }
648    }
649
650    impl Sv2ClientsMonitoring for ContendedMonitoring {
651        fn get_sv2_clients(&self) -> Vec<Sv2ClientInfo> {
652            let _guard = self.business_lock.lock().unwrap();
653            self.monitoring_lock_acquisitions
654                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
655            // Minimal sleep to simulate lock acquisition overhead
656            std::thread::sleep(Duration::from_micros(10));
657            vec![]
658        }
659    }
660
661    impl ServerMonitoring for ContendedMonitoring {
662        fn get_server(&self) -> ServerInfo {
663            let _guard = self.business_lock.lock().unwrap();
664            self.monitoring_lock_acquisitions
665                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
666            // Minimal sleep to simulate lock acquisition overhead
667            std::thread::sleep(Duration::from_micros(10));
668            ServerInfo {
669                extended_channels: vec![],
670                standard_channels: vec![],
671            }
672        }
673    }
674
675    /// Verifies that the snapshot cache eliminates lock contention.
676    ///
677    /// Without the cache, monitoring API requests would acquire the same lock
678    /// used by business logic (share validation, job distribution), causing
679    /// performance degradation. The cache decouples these operations by
680    /// periodically refreshing a snapshot that API requests read from.
681    #[test]
682    fn test_snapshot_cache_eliminates_lock_contention() {
683        let real_monitoring = Arc::new(ContendedMonitoring::new(Duration::from_millis(1)));
684
685        let cache = Arc::new(SnapshotCache::new(
686            Duration::from_secs(5),
687            None,
688            Some(real_monitoring.clone() as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
689        ));
690
691        cache.refresh();
692
693        // Simulate business logic running concurrently
694        let business_mon = Arc::clone(&real_monitoring);
695        let business_handle = std::thread::spawn(move || {
696            let start = std::time::Instant::now();
697            let mut ops = 0u64;
698            while start.elapsed() < Duration::from_millis(100) {
699                business_mon.simulate_business_logic();
700                ops += 1;
701            }
702            ops
703        });
704
705        // Simulate rapid API requests via cache (16 threads for higher throughput)
706        let mut monitoring_handles = vec![];
707        for _ in 0..16 {
708            let cache_ref = Arc::clone(&cache);
709            monitoring_handles.push(std::thread::spawn(move || {
710                let start = std::time::Instant::now();
711                let mut requests = 0u64;
712                // Tight loop - cache reads are extremely fast
713                while start.elapsed() < Duration::from_millis(100) {
714                    let _ = cache_ref.get_snapshot();
715                    requests += 1;
716                }
717                requests
718            }));
719        }
720
721        let _business_ops = business_handle.join().unwrap();
722        let total_cache_requests: u64 = monitoring_handles
723            .into_iter()
724            .map(|h| h.join().unwrap())
725            .sum();
726
727        let real_lock_acquisitions = real_monitoring.get_monitoring_acquisitions();
728
729        // Cache should only acquire lock during refresh (1-2 times), not per request
730        assert!(
731            real_lock_acquisitions <= 2,
732            "Cache acquired lock {} times, expected ≤2 (refresh only)",
733            real_lock_acquisitions
734        );
735
736        // Cache should enable high throughput without acquiring business logic locks
737        assert!(
738            total_cache_requests > 2,
739            "Cache should have processed requests",
740        );
741    }
742}