relay_knowledge/application/service/
health.rs1use 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 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 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}