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