1use 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
60const 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#[derive(Default)]
80struct PreviousPrometheusLabelSets {
81 server_channel_labels: HashSet<[String; 2]>,
83 server_rejected_share_labels: HashSet<[String; 3]>,
85 client_channel_labels: HashSet<[String; 3]>,
87 client_rejected_share_labels: HashSet<[String; 4]>,
90}
91
92#[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
107pub 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 let current_snapshot = self.snapshot.read().unwrap().clone();
129 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 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 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 pub fn with_metrics(mut self, metrics: PrometheusMetrics) -> Self {
198 self.metrics = Some(metrics);
199 self
200 }
201
202 pub fn get_snapshot(&self) -> MonitoringSnapshot {
207 self.snapshot.read().unwrap().clone()
208 }
209
210 pub fn refresh(&self) {
219 let mut new_snapshot = MonitoringSnapshot {
220 timestamp: Some(Instant::now()),
221 ..Default::default()
222 };
223
224 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 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 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 if let Some(ref metrics) = self.metrics {
244 self.update_metrics(metrics, &new_snapshot);
245 }
246
247 *self.snapshot.write().unwrap() = new_snapshot;
249 }
250
251 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 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 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 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 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 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 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 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 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(¤t_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(¤t_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(¤t_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(¤t_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 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 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 let snapshot = cache.get_snapshot();
608 assert!(snapshot.timestamp.is_none());
609 assert!(snapshot.server_info.is_none());
610
611 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 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 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 std::thread::sleep(Duration::from_micros(10));
668 ServerInfo {
669 extended_channels: vec![],
670 standard_channels: vec![],
671 }
672 }
673 }
674
675 #[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 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 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 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 assert!(
731 real_lock_acquisitions <= 2,
732 "Cache acquired lock {} times, expected ≤2 (refresh only)",
733 real_lock_acquisitions
734 );
735
736 assert!(
738 total_cache_requests > 2,
739 "Cache should have processed requests",
740 );
741 }
742}