Skip to main content

relay_knowledge/application/service/
health.rs

1use std::{sync::Arc, time::Duration};
2
3use crate::{
4    api::{ApiError, ApiMetadata, HealthResponse, RequestContext, StorageTopologyDiagnostics},
5    domain::{CodeRepositoryTotals, GraphVersion},
6    storage::{
7        FileIndexDiagnostics, GraphInspection, HealthStorageSnapshot, IndexRefreshDiagnostics,
8        KnowledgeStore, StorageError,
9    },
10};
11
12use super::{
13    RelayKnowledgeService, current_time_millis, graph_with_repository_code_totals,
14    storage_api_error,
15};
16use crate::application::{
17    knowledge::index_refresh::{
18        IndexRefreshOutcome, filter_outcome_to_read_models, metadata_for_indexes,
19    },
20    status::runtime_status_with_model_profiles,
21};
22
23const HEALTH_STORAGE_BUDGET: Duration = Duration::from_millis(500);
24
25struct HealthStorageReport {
26    snapshot: HealthStorageSnapshot,
27    storage: StorageTopologyDiagnostics,
28    degraded_reason: Option<String>,
29}
30
31impl RelayKnowledgeService {
32    /// Returns liveness-safe service and data health diagnostics.
33    pub async fn health(&self, context: RequestContext) -> Result<HealthResponse, ApiError> {
34        let store = self.storage.get().await.map_err(storage_api_error)?;
35        match tokio::time::timeout(HEALTH_STORAGE_BUDGET, self.health_storage_report(&store)).await
36        {
37            Ok(Ok(report)) => {
38                let response = self.health_from_storage_report(context, report).await;
39                *self.health_cache.write().await = Some(response.clone());
40                Ok(response)
41            }
42            Ok(Err(StorageError::Busy(message))) => Ok(self
43                .degraded_cached_health(context, format!("storage_busy: {message}"))
44                .await),
45            Ok(Err(error)) => Err(storage_api_error(error)),
46            Err(_) => Ok(self
47                .degraded_cached_health(context, "storage_busy: health snapshot timed out")
48                .await),
49        }
50    }
51
52    /// Returns control-plane health without opening cold graph storage.
53    pub async fn read_only_health(
54        &self,
55        context: RequestContext,
56    ) -> Result<HealthResponse, ApiError> {
57        if self.storage.ready_store().is_some() {
58            return self.health(context).await;
59        }
60
61        match tokio::time::timeout(
62            HEALTH_STORAGE_BUDGET,
63            self.storage_free_health(context.clone()),
64        )
65        .await
66        {
67            Ok(response) => Ok(response),
68            Err(_) => Ok(self
69                .degraded_cached_health(context, "storage_busy: cold health snapshot timed out")
70                .await),
71        }
72    }
73
74    async fn storage_free_health(&self, context: RequestContext) -> HealthResponse {
75        let storage = self
76            .storage_topology_diagnostics_with_budget(HEALTH_STORAGE_BUDGET)
77            .await;
78        let degraded_reason = storage.degraded_reason.clone();
79
80        HealthResponse {
81            metadata: ApiMetadata::graph_only(&context, GraphVersion::ZERO),
82            healthy: degraded_reason.is_none(),
83            degraded_reason,
84            storage,
85            graph: GraphInspection::default(),
86            repository_code_totals: CodeRepositoryTotals::default(),
87            indexes: Vec::new(),
88            index_cursors: Vec::new(),
89            index_refresh: IndexRefreshDiagnostics::default(),
90            file_index: FileIndexDiagnostics::default(),
91            runtime: runtime_status_with_model_profiles(
92                &self.runtime,
93                self.model_provider_config()
94                    .profile_summary(&self.runtime.retrieval)
95                    .await,
96            ),
97        }
98    }
99
100    async fn health_storage_report(
101        &self,
102        store: &Arc<dyn KnowledgeStore>,
103    ) -> Result<HealthStorageReport, StorageError> {
104        let snapshot = match self.storage_health_snapshot(store).await {
105            Ok(snapshot) => snapshot,
106            Err(error) => {
107                let storage = self.storage_topology_diagnostics().await;
108                if storage.missing_shard_count == 0 {
109                    return Err(error);
110                }
111                return Ok(HealthStorageReport {
112                    snapshot: self
113                        .degraded_health_snapshot_without_repository_totals(store)
114                        .await?,
115                    degraded_reason: storage
116                        .degraded_reason
117                        .clone()
118                        .or_else(|| Some(error.to_string())),
119                    storage,
120                });
121            }
122        };
123        let storage = self.storage_topology_diagnostics().await;
124
125        Ok(HealthStorageReport {
126            snapshot,
127            storage,
128            degraded_reason: None,
129        })
130    }
131
132    async fn storage_health_snapshot(
133        &self,
134        store: &Arc<dyn KnowledgeStore>,
135    ) -> Result<HealthStorageSnapshot, StorageError> {
136        match store.health_snapshot(current_time_millis()).await {
137            Ok(snapshot) => Ok(snapshot),
138            Err(StorageError::InvalidInput(message))
139                if message == "health snapshot storage is unavailable" =>
140            {
141                self.legacy_health_snapshot(store).await
142            }
143            Err(error) => Err(error),
144        }
145    }
146
147    async fn health_from_storage_report(
148        &self,
149        context: RequestContext,
150        report: HealthStorageReport,
151    ) -> HealthResponse {
152        let HealthStorageReport {
153            snapshot,
154            storage,
155            degraded_reason,
156        } = report;
157        let HealthStorageSnapshot {
158            graph,
159            repository_code_totals,
160            indexes,
161            index_cursors,
162            index_refresh,
163            file_index,
164        } = snapshot;
165        let graph = graph_with_repository_code_totals(graph, &repository_code_totals);
166        let degraded_reason = degraded_reason.or_else(|| storage.degraded_reason.clone());
167        let outcome = filter_outcome_to_read_models(
168            IndexRefreshOutcome {
169                indexes,
170                cursors: index_cursors,
171                diagnostics: index_refresh,
172            },
173            &self.runtime.retrieval,
174        );
175        let healthy = degraded_reason.is_none()
176            && outcome
177                .indexes
178                .iter()
179                .all(|status| !status.is_stale_for(graph.graph_version));
180
181        HealthResponse {
182            metadata: metadata_for_indexes(&context, graph.graph_version, &outcome.indexes),
183            healthy,
184            degraded_reason,
185            storage,
186            graph,
187            repository_code_totals,
188            indexes: outcome.indexes,
189            index_cursors: outcome.cursors,
190            index_refresh: outcome.diagnostics,
191            file_index,
192            runtime: runtime_status_with_model_profiles(
193                &self.runtime,
194                self.model_provider_config()
195                    .profile_summary(&self.runtime.retrieval)
196                    .await,
197            ),
198        }
199    }
200
201    async fn legacy_health_snapshot(
202        &self,
203        store: &Arc<dyn KnowledgeStore>,
204    ) -> Result<HealthStorageSnapshot, StorageError> {
205        Ok(HealthStorageSnapshot {
206            graph: store.inspect_graph().await?,
207            repository_code_totals: store.code_repository_totals().await?,
208            indexes: store.index_statuses().await?,
209            index_cursors: store.index_cursors().await?,
210            index_refresh: store
211                .index_refresh_diagnostics(current_time_millis())
212                .await?,
213            file_index: legacy_file_index_diagnostics_or_default(store).await?,
214        })
215    }
216
217    async fn degraded_health_snapshot_without_repository_totals(
218        &self,
219        store: &Arc<dyn KnowledgeStore>,
220    ) -> Result<HealthStorageSnapshot, StorageError> {
221        Ok(HealthStorageSnapshot {
222            graph: store.inspect_graph().await?,
223            repository_code_totals: CodeRepositoryTotals::default(),
224            indexes: store.index_statuses().await?,
225            index_cursors: store.index_cursors().await?,
226            index_refresh: store
227                .index_refresh_diagnostics(current_time_millis())
228                .await?,
229            file_index: legacy_file_index_diagnostics_or_default(store).await?,
230        })
231    }
232
233    async fn degraded_cached_health(
234        &self,
235        context: RequestContext,
236        degraded_reason: impl Into<String>,
237    ) -> HealthResponse {
238        let degraded_reason = degraded_reason.into();
239        if let Some(cached) = self.health_cache.read().await.clone() {
240            let mut response = cached;
241            response.metadata.trace_id = context.trace_id;
242            response.metadata.request_id = context.request_id;
243            response.metadata.stale = true;
244            response.healthy = false;
245            response.degraded_reason = Some(degraded_reason);
246            return response;
247        }
248
249        HealthResponse {
250            metadata: ApiMetadata::indexed(&context, GraphVersion::ZERO, None, None, true),
251            healthy: false,
252            degraded_reason: Some(degraded_reason),
253            storage: self.storage_diagnostics_from_snapshot(Default::default(), None),
254            graph: GraphInspection::default(),
255            repository_code_totals: CodeRepositoryTotals::default(),
256            indexes: Vec::new(),
257            index_cursors: Vec::new(),
258            index_refresh: IndexRefreshDiagnostics::default(),
259            file_index: FileIndexDiagnostics::default(),
260            runtime: runtime_status_with_model_profiles(
261                &self.runtime,
262                self.model_provider_config()
263                    .profile_summary(&self.runtime.retrieval)
264                    .await,
265            ),
266        }
267    }
268}
269
270async fn legacy_file_index_diagnostics_or_default(
271    store: &Arc<dyn KnowledgeStore>,
272) -> Result<FileIndexDiagnostics, StorageError> {
273    match store.file_index_diagnostics().await {
274        Ok(diagnostics) => Ok(diagnostics),
275        Err(StorageError::InvalidInput(message))
276            if message == "file index storage is unavailable" =>
277        {
278            Ok(FileIndexDiagnostics::default())
279        }
280        Err(error) => Err(error),
281    }
282}