Skip to main content

linkrs_server/http/handlers/statistics/
database.rs

1//! Database overview handlers.
2//!
3//! Assembles storage and performance counters into the database overview.
4
5use axum::{extract::State, response::Json as JsonResponse};
6
7use crate::http::{error::HttpError, state::AppState};
8use crate::storage::{
9    StorageClient, StorageOperationContextOps, StorageSchemaContextOps, StorageSyncContextOps,
10};
11use linkrs_metrics::MetricType;
12use linkrs_wire::meta::{
13    CheckpointSummary, DatabaseOverviewResponse, DatabasePerformance, DatabaseSearchSummary,
14    DatabaseSpaces, DatabaseStorage, LatencyPercentilesUs,
15};
16
17#[utoipa::path(
18    get,
19    path = "/v1/statistics/database",
20    tag = "Statistics",
21    responses(
22        (status = 200, body = DatabaseOverviewResponse, description = "Database statistics"),
23        (status = 500, description = "Internal error")
24    )
25)]
26/// Obtain database statistics
27pub async fn database<
28    S: StorageClient
29        + StorageSchemaContextOps
30        + StorageSyncContextOps
31        + StorageOperationContextOps
32        + Clone
33        + Send
34        + Sync
35        + 'static,
36>(
37    State(state): State<AppState<S>>,
38) -> Result<JsonResponse<DatabaseOverviewResponse>, HttpError> {
39    let stats_manager = state.server.get_stats_manager();
40    let storage = state.server.get_storage();
41
42    // Using `spawn_blocking` in an asynchronous context to acquire a storage lock
43    let storage_stats = {
44        let storage = storage.clone();
45        tokio::task::spawn_blocking(move || {
46            let storage = storage.read();
47            storage.get_storage_stats()
48        })
49        .await
50        .map_err(|e| HttpError::internal(format!("Failed to get storage statistics: {:?}", e)))?
51    };
52
53    Ok(JsonResponse(collect_database_overview(
54        stats_manager,
55        &storage_stats,
56    )))
57}
58
59pub(crate) fn collect_database_overview(
60    stats_manager: &linkrs_metrics::StatsManager,
61    storage_stats: &crate::storage::StorageStats,
62) -> DatabaseOverviewResponse {
63    // Obtain statistics related to the query from both sources
64    let total_queries = stats_manager.get_value(MetricType::NumQueries).unwrap_or(0);
65    let active_queries = stats_manager
66        .get_value(MetricType::NumActiveQueries)
67        .unwrap_or(0);
68
69    // Obtain the cache size
70    let cache_size = stats_manager.query_cache_size();
71
72    // Calculating performance metrics over the recent window.
73    let recent_queries = stats_manager.get_recent_queries(100);
74    let avg_latency_ms = if recent_queries.is_empty() {
75        0.0
76    } else {
77        recent_queries
78            .iter()
79            .map(|q| q.total_duration_us)
80            .sum::<u64>() as f64
81            / recent_queries.len() as f64
82            / 1000.0
83    };
84
85    // QPS from the per-second aggregation, not from recent-profile spans.
86    let qps = stats_manager.get_current_qps() as f64;
87
88    // Obtain search metrics
89    let num_search_queries = stats_manager
90        .get_value(MetricType::NumSearchQueries)
91        .unwrap_or(0);
92    let num_search_errors = stats_manager
93        .get_value(MetricType::NumSearchErrors)
94        .unwrap_or(0);
95    let search_latency_ms = stats_manager
96        .get_value(MetricType::SearchLatencyMs)
97        .unwrap_or(0);
98    let num_index_operations = stats_manager
99        .get_value(MetricType::NumIndexOperations)
100        .unwrap_or(0);
101    let num_delete_operations = stats_manager
102        .get_value(MetricType::NumDeleteOperations)
103        .unwrap_or(0);
104    let cache_hit_count = stats_manager
105        .get_value(MetricType::SearchCacheHitCount)
106        .unwrap_or(0);
107    let cache_miss_count = stats_manager
108        .get_value(MetricType::SearchCacheMissCount)
109        .unwrap_or(0);
110
111    let avg_search_latency_ms = if num_search_queries > 0 {
112        search_latency_ms as f64 / num_search_queries as f64
113    } else {
114        0.0
115    };
116
117    let search_cache_hit_rate = if cache_hit_count + cache_miss_count > 0 {
118        cache_hit_count as f64 / (cache_hit_count + cache_miss_count) as f64
119    } else {
120        0.0
121    };
122
123    let latency = stats_manager.query_latency_snapshot();
124    let errors = stats_manager.error_snapshot();
125    let storage_snapshot = stats_manager.storage_snapshot();
126    let checkpoint = stats_manager.checkpoint_snapshot();
127
128    DatabaseOverviewResponse {
129        spaces: DatabaseSpaces {
130            count: storage_stats.total_spaces,
131            total_vertices: storage_stats.total_vertices,
132            total_edges: storage_stats.total_edges,
133        },
134        storage: DatabaseStorage {
135            // StorageStats semantics: total is allocated bytes including
136            // holes and overhead, data is the live estimate, index is the
137            // derived residual `total - data` (fragmentation plus overhead).
138            total_size_bytes: storage_stats.total_size_bytes,
139            index_size_bytes: storage_stats.index_size_bytes,
140            data_size_bytes: storage_stats.data_size_bytes,
141            fragmentation_permille: storage_snapshot.fragmentation_permille,
142            wasted_bytes: storage_snapshot.wasted_bytes,
143            tombstone_count: storage_snapshot.tombstone_count,
144            tombstone_memory_bytes: storage_snapshot.tombstone_memory_bytes,
145            dirty_pages: storage_snapshot.dirty_pages,
146            dirty_pages_total: storage_snapshot.dirty_pages_total,
147            checkpoint: CheckpointSummary {
148                success_count: checkpoint.success_count,
149                failure_count: checkpoint.failure_count,
150                trigger_count: checkpoint.trigger_count,
151                avg_duration_us: checkpoint.avg_duration_us,
152                triggered_by_wal_size: checkpoint.triggered_by_wal_size,
153                triggered_by_interval: checkpoint.triggered_by_interval,
154                triggered_explicit: checkpoint.triggered_explicit,
155            },
156        },
157        performance: DatabasePerformance {
158            total_queries,
159            active_queries,
160            query_cache_size: cache_size,
161            queries_per_second: qps,
162            avg_latency_ms,
163            cache_hit_rate: search_cache_hit_rate,
164            cache_hit_rate_source: "search_cache".to_string(),
165            error_total: errors.total_errors,
166            latency_percentiles_us: Some(LatencyPercentilesUs {
167                avg: latency.avg_us,
168                p50: latency.p50_us,
169                p95: latency.p95_us,
170                p99: latency.p99_us,
171            }),
172        },
173        search: DatabaseSearchSummary {
174            total_queries: num_search_queries,
175            total_errors: num_search_errors,
176            avg_latency_ms: avg_search_latency_ms,
177            total_index_operations: num_index_operations,
178            total_delete_operations: num_delete_operations,
179            cache_hit_count,
180            cache_miss_count,
181            cache_hit_rate: search_cache_hit_rate,
182        },
183    }
184}