1use std::sync::{Arc, OnceLock};
5
6use async_trait::async_trait;
7use authz_resolver_sdk::pep::PolicyEnforcer;
8use toolkit::api::OpenApiRegistry;
9use toolkit::{
10 DatabaseCapability, Gear, GearCtx, Healthcheck, HealthcheckResult, RestApiCapability,
11};
12use tracing::{debug, error, info, warn};
13
14use graph_storage_sdk::GraphStorageClientV1;
15use graph_storage_sdk::plugin_api::EmbeddingProviderV1;
16
17use crate::api::rest::routes;
18use crate::config::{EmbeddingProviderKind, GraphStorageConfig};
19use crate::domain::embedding::SpaceState;
20use crate::domain::local_client::GraphStorageLocalClient;
21use crate::domain::service::GraphServices;
22use crate::infra::embedding::fake::FakeEmbeddingProvider;
23use crate::infra::engine::PgGraphEngine;
24use crate::infra::store::{PgGraphStore, spaces};
25
26#[toolkit::gear(name = "graph-storage", deps = [authz_resolver], capabilities = [db, rest])]
28pub struct GraphStorage {
29 services: OnceLock<Arc<GraphServices>>,
30}
31
32impl Default for GraphStorage {
33 fn default() -> Self {
34 Self {
35 services: OnceLock::new(),
36 }
37 }
38}
39
40#[async_trait]
41impl Gear for GraphStorage {
42 async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> {
43 let cfg = ctx.config_or_default::<GraphStorageConfig>()?.validated()?;
44 debug!(
45 traversal_hop = ?cfg.traversal_hop,
46 ingest_max_nodes = cfg.ingest_max_nodes,
47 "loaded graph-storage configuration"
48 );
49
50 let migrated = crate::infra::store::ingest::migrated_embedding_dimension();
54 if cfg.embedding_dimension != migrated {
55 anyhow::bail!(
56 "graph-storage.embedding_dimension is {} but the schema was migrated with {migrated}; \
57 vector search would compare incomparable vectors",
58 cfg.embedding_dimension
59 );
60 }
61
62 let db_raw = ctx.db_required()?;
66 let db = Arc::new(db_raw.db());
67
68 let pgq_available = crate::infra::engine::probe_pgq(&db).await;
72 if !pgq_available {
73 match cfg.traversal_hop {
74 crate::config::HopStrategy::Auto => warn!(
75 "this server does not provide SQL/PGQ; traversal will use the two-query hop"
76 ),
77 crate::config::HopStrategy::Pgq => error!(
78 "traversal_hop is `pgq` and this server does not provide SQL/PGQ; the gear \
79 reports not ready and refuses traversal rather than substitute another \
80 backend"
81 ),
82 crate::config::HopStrategy::TwoQuery => {}
83 }
84 }
85
86 let store = Arc::new(PgGraphStore::new(
87 Arc::clone(&db),
88 cfg.clone(),
89 pgq_available,
90 ));
91 let engine = Arc::new(PgGraphEngine::new(Arc::clone(&store)));
92 let enforcer = PolicyEnforcer::new(ctx.client_hub().get()?);
93
94 let provider = select_embedding_provider(&cfg).await?;
95 let embedding = resolve_embedding_space(&db, provider, &cfg).await?;
96
97 let services = Arc::new(GraphServices::new(cfg, store, engine, enforcer, embedding));
98 self.services
99 .set(Arc::clone(&services))
100 .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?;
101
102 ctx.client_hub()
103 .register::<dyn GraphStorageClientV1>(Arc::new(GraphStorageLocalClient::new(services)));
104
105 info!(pgq_available, "graph-storage gear initialized");
106 Ok(())
107 }
108}
109
110async fn select_embedding_provider(
122 cfg: &GraphStorageConfig,
123) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
124 let Some(kind) = cfg.embedding_provider else {
125 anyhow::bail!(
126 "graph-storage.embedding_provider is not set; name one of `fake`, `onnx` or \
127 `remote` -- the gear does not fall back to the fake, whose vectors rank nothing \
128 meaningfully"
129 );
130 };
131 match kind {
132 EmbeddingProviderKind::Fake => {
133 warn!(
134 "graph-storage.embedding_provider is `fake`: vector search will answer, \
135 but its ranking carries no semantics"
136 );
137 Ok(Arc::new(FakeEmbeddingProvider::new(
138 cfg.embedding_dimension,
139 )))
140 }
141 EmbeddingProviderKind::Onnx => onnx_provider(cfg).await,
142 EmbeddingProviderKind::Remote => remote_provider(cfg),
143 }
144}
145
146#[cfg(feature = "remote")]
147fn remote_provider(cfg: &GraphStorageConfig) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
148 let named = |key: &str, value: &Option<String>| -> anyhow::Result<String> {
149 value.clone().ok_or_else(|| {
150 anyhow::anyhow!("graph-storage.{key} is required by the `remote` embedding provider")
151 })
152 };
153 let mut config = remote_embedding_plugin::RemoteProviderConfig::new(
154 named("embedding_remote_base_url", &cfg.embedding_remote_base_url)?,
155 named("embedding_remote_model", &cfg.embedding_remote_model)?,
156 );
157 config.dimension = cfg.embedding_dimension;
158 config.request_dimensions = cfg.embedding_remote_request_dimensions;
159 config.batch_size = cfg.embedding_remote_batch_size as usize;
160 config.timeout = std::time::Duration::from_secs(cfg.embedding_remote_timeout_secs);
161
162 if let Some(variable) = &cfg.embedding_remote_api_key_env {
165 let value = std::env::var(variable).map_err(|_| {
166 anyhow::anyhow!(
167 "graph-storage.embedding_remote_api_key_env names {variable}, which is not set \
168 in this process's environment"
169 )
170 })?;
171 if value.trim().is_empty() {
172 anyhow::bail!(
173 "graph-storage.embedding_remote_api_key_env names {variable}, which is empty"
174 );
175 }
176 config = config.with_api_key(value);
177 }
178
179 let provider = remote_embedding_plugin::RemoteEmbeddingProvider::new(config)?;
180 info!(
181 endpoint = %provider.endpoint(),
182 model = %provider.embedding_space().model_artifact,
183 "configured the remote embedding provider"
184 );
185 Ok(Arc::new(provider))
186}
187
188#[cfg(not(feature = "remote"))]
189fn remote_provider(_cfg: &GraphStorageConfig) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
190 anyhow::bail!(
191 "graph-storage.embedding_provider is `remote` but this binary was built without the \
192 `remote` feature; rebuild with it or choose another provider"
193 )
194}
195
196#[cfg(feature = "onnx")]
197async fn onnx_provider(cfg: &GraphStorageConfig) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
198 let named = |key: &str, value: &Option<String>| -> anyhow::Result<String> {
199 value.clone().ok_or_else(|| {
200 anyhow::anyhow!("graph-storage.{key} is required by the `onnx` embedding provider")
201 })
202 };
203 let mut config = onnx_embedding_plugin::OnnxProviderConfig::new(
204 named("embedding_model_path", &cfg.embedding_model_path)?,
205 named("embedding_tokenizer_path", &cfg.embedding_tokenizer_path)?,
206 );
207 config.dimension = cfg.embedding_dimension;
208
209 let provider = onnx_embedding_plugin::OnnxEmbeddingProvider::load(config).await?;
213 info!(
214 model = %provider.embedding_space().model_artifact,
215 "loaded the in-process ONNX embedding provider"
216 );
217 Ok(Arc::new(provider))
218}
219
220#[cfg(not(feature = "onnx"))]
221#[expect(
222 clippy::unused_async,
223 reason = "one signature for both builds; the feature-enabled arm is async"
224)]
225async fn onnx_provider(_cfg: &GraphStorageConfig) -> anyhow::Result<Arc<dyn EmbeddingProviderV1>> {
226 anyhow::bail!(
227 "graph-storage.embedding_provider is `onnx` but this binary was built without the \
228 `onnx` feature; rebuild with it or choose another provider"
229 )
230}
231
232async fn resolve_embedding_space(
239 db: &toolkit_db::secure::Db,
240 provider: Arc<dyn EmbeddingProviderV1>,
241 cfg: &GraphStorageConfig,
242) -> anyhow::Result<crate::domain::embedding::EmbeddingCoordinator> {
243 if provider.dimension() != cfg.embedding_dimension {
247 anyhow::bail!(
248 "the embedding provider declares {} dimensions but \
249 graph-storage.embedding_dimension is {}",
250 provider.dimension(),
251 cfg.embedding_dimension
252 );
253 }
254
255 let state = match spaces::resolve(db, provider.embedding_space()).await? {
256 spaces::SpaceResolution::Active { epoch } => {
257 info!(
258 epoch,
259 identity = %provider.embedding_space().identity_hash,
260 model = %provider.embedding_space().model_artifact,
261 "embedding space active"
262 );
263 SpaceState::Active { epoch }
264 }
265 spaces::SpaceResolution::Mismatched {
266 recorded_identity,
267 recorded_epoch,
268 } => {
269 error!(
270 recorded_epoch,
271 recorded_identity = %recorded_identity,
272 active_identity = %provider.embedding_space().identity_hash,
273 "stored vectors belong to a different embedding space than the configured \
274 provider; vector search is blocked until the graph is re-embedded"
275 );
276 SpaceState::Blocked
277 }
278 };
279
280 Ok(crate::domain::embedding::EmbeddingCoordinator::new(
281 provider,
282 state,
283 cfg.embedding_input_max_bytes,
284 ))
285}
286
287impl DatabaseCapability for GraphStorage {
288 fn migrations(&self) -> Vec<Box<dyn sea_orm_migration::MigrationTrait>> {
289 use sea_orm_migration::MigratorTrait;
290 crate::infra::storage::migrations::Migrator::migrations()
291 }
292}
293
294impl RestApiCapability for GraphStorage {
295 fn register_rest(
296 &self,
297 _ctx: &GearCtx,
298 router: axum::Router,
299 openapi: &dyn OpenApiRegistry,
300 ) -> anyhow::Result<axum::Router> {
301 let services = self
302 .services
303 .get()
304 .ok_or_else(|| anyhow::anyhow!("graph-storage services are not initialized"))?
305 .clone();
306 Ok(routes::register_routes(router, openapi, services))
307 }
308
309 fn healthcheck(&self, _ctx: &GearCtx) -> Option<Arc<dyn Healthcheck>> {
314 let services = self.services.get()?.clone();
315 Some(Arc::new(PlatformReadiness { services }))
316 }
317}
318
319struct PlatformReadiness {
320 services: Arc<GraphServices>,
321}
322
323#[async_trait]
324impl Healthcheck for PlatformReadiness {
325 fn name(&self) -> &'static str {
326 "graph-storage"
327 }
328
329 async fn check(&self) -> HealthcheckResult {
330 platform_result(&self.services.readiness().await)
331 }
332}
333
334fn platform_result(readiness: &graph_storage_sdk::models::Readiness) -> HealthcheckResult {
344 use graph_storage_sdk::models::ReadinessState;
345
346 let named = |fatal: bool| {
347 readiness
348 .components
349 .iter()
350 .filter(|row| {
351 if fatal {
352 row.fatal()
353 } else {
354 matches!(
355 row.state,
356 ReadinessState::Degraded | ReadinessState::Unhealthy
357 )
358 }
359 })
360 .map(|row| row.component.as_str())
361 .collect::<Vec<_>>()
362 .join(", ")
363 };
364 if !readiness.ready {
365 return HealthcheckResult::unhealthy(format!("not ready: {}", named(true)))
366 .with_code("graph_storage.not_ready");
367 }
368 let degraded = named(false);
369 if degraded.is_empty() {
370 HealthcheckResult::healthy()
371 } else {
372 HealthcheckResult::degraded(format!("degraded: {degraded}"))
373 .with_code("graph_storage.degraded")
374 }
375}
376
377#[cfg(test)]
378mod platform_readiness_tests {
379 use graph_storage_sdk::models::{
380 ComponentReadiness, DATABASE, DYNAMIC_INDEXES, EMBEDDING_SPACE, Readiness, ReadinessState,
381 };
382 use toolkit::HealthcheckStatus;
383
384 use super::platform_result;
385
386 fn row(component: &str, state: ReadinessState) -> ComponentReadiness {
387 ComponentReadiness::new(
388 component,
389 state,
390 "a problem text the platform must not see",
391 "what it blocks",
392 "recovery",
393 )
394 }
395
396 #[test]
397 fn all_healthy_is_healthy_and_a_missing_capability_is_not_a_fault() {
398 let result = platform_result(&Readiness::of(vec![
399 ComponentReadiness::healthy(DATABASE),
400 row(DYNAMIC_INDEXES, ReadinessState::NotImplemented),
401 ]));
402 assert_eq!(result.status, HealthcheckStatus::Healthy, "{result:?}");
403 assert_eq!(result.code, None);
404 }
405
406 #[test]
408 fn a_space_mismatch_degrades_and_names_only_the_component() {
409 let result = platform_result(&Readiness::of(vec![
410 ComponentReadiness::healthy(DATABASE),
411 row(EMBEDDING_SPACE, ReadinessState::Unhealthy),
412 ]));
413 assert_eq!(result.status, HealthcheckStatus::Degraded, "{result:?}");
414 assert_eq!(result.code.as_deref(), Some("graph_storage.degraded"));
415 let message = result.message.unwrap_or_default();
416 assert!(message.contains(EMBEDDING_SPACE), "{message}");
417 assert!(!message.contains("problem text"), "{message}");
418 }
419
420 #[test]
421 fn not_ready_is_unhealthy_and_names_what_blocks_it() {
422 let result = platform_result(&Readiness::of(vec![
423 row(DATABASE, ReadinessState::Unhealthy),
424 row(EMBEDDING_SPACE, ReadinessState::Unhealthy),
425 ]));
426 assert_eq!(result.status, HealthcheckStatus::Unhealthy, "{result:?}");
427 assert_eq!(result.code.as_deref(), Some("graph_storage.not_ready"));
428 assert_eq!(result.message, Some(format!("not ready: {DATABASE}")));
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::{EmbeddingProviderKind, GraphStorageConfig, select_embedding_provider};
440
441 fn with(kind: Option<EmbeddingProviderKind>) -> GraphStorageConfig {
442 GraphStorageConfig {
443 embedding_provider: kind,
444 ..GraphStorageConfig::default()
445 }
446 }
447
448 #[tokio::test]
451 async fn an_unset_provider_is_refused_and_says_what_to_set() {
452 let refused = select_embedding_provider(&with(None))
453 .await
454 .err()
455 .expect("no provider is not a default");
456 let message = refused.to_string();
457 for named in ["embedding_provider", "fake", "onnx", "remote"] {
458 assert!(message.contains(named), "{named} is named: {message}");
459 }
460 }
461
462 #[tokio::test]
463 async fn the_fake_is_wired_at_the_configured_dimension() {
464 let config = GraphStorageConfig {
465 embedding_dimension: 16,
466 ..with(Some(EmbeddingProviderKind::Fake))
467 };
468 let provider = select_embedding_provider(&config)
469 .await
470 .expect("the fake needs nothing");
471 assert_eq!(provider.embedding_space().dimension, 16);
472 }
473
474 #[cfg(feature = "remote")]
477 #[tokio::test]
478 async fn remote_is_wired_from_the_configuration() {
479 let configured = GraphStorageConfig {
480 embedding_dimension: 32,
483 embedding_remote_base_url: Some("http://127.0.0.1:9/v1".to_owned()),
484 embedding_remote_model: Some("wired-model".to_owned()),
485 ..with(Some(EmbeddingProviderKind::Remote))
486 };
487 let provider = select_embedding_provider(&configured)
488 .await
489 .expect("a complete remote configuration boots");
490 assert_eq!(
494 provider.embedding_space().model_artifact,
495 "wired-model@http://127.0.0.1:9/v1/embeddings"
496 );
497 assert_eq!(provider.embedding_space().dimension, 32);
498
499 for (missing, key) in [
500 (
501 GraphStorageConfig {
502 embedding_remote_base_url: None,
503 ..configured.clone()
504 },
505 "embedding_remote_base_url",
506 ),
507 (
508 GraphStorageConfig {
509 embedding_remote_model: None,
510 ..configured.clone()
511 },
512 "embedding_remote_model",
513 ),
514 ] {
515 let refused = select_embedding_provider(&missing)
516 .await
517 .err()
518 .expect("a required key is required");
519 assert!(refused.to_string().contains(key), "{key}: {refused}");
520 }
521
522 let unset = GraphStorageConfig {
525 embedding_remote_api_key_env: Some(
526 "GRAPH_STORAGE_TEST_CREDENTIAL_THAT_IS_NEVER_SET".to_owned(),
527 ),
528 ..configured
529 };
530 let refused = select_embedding_provider(&unset)
531 .await
532 .err()
533 .expect("an unset credential variable stops the boot");
534 assert!(
535 refused.to_string().contains("not set"),
536 "the refusal says why: {refused}"
537 );
538 }
539
540 #[cfg(not(feature = "remote"))]
543 #[tokio::test]
544 async fn remote_without_the_feature_says_so() {
545 let refused = select_embedding_provider(&with(Some(EmbeddingProviderKind::Remote)))
546 .await
547 .err()
548 .expect("a provider the binary lacks is refused");
549 assert!(
550 refused.to_string().contains("`remote` feature"),
551 "{refused}"
552 );
553 }
554
555 #[cfg(feature = "onnx")]
559 #[tokio::test]
560 async fn onnx_names_the_artifact_it_is_missing() {
561 let refused = select_embedding_provider(&with(Some(EmbeddingProviderKind::Onnx)))
562 .await
563 .err()
564 .expect("no artifacts, no provider");
565 assert!(
566 refused.to_string().contains("embedding_model_path"),
567 "{refused}"
568 );
569 }
570
571 #[cfg(feature = "onnx")]
576 #[tokio::test]
577 async fn onnx_is_wired_from_the_configuration() {
578 let (Ok(model), Ok(tokenizer)) = (
579 std::env::var("GRAPH_STORAGE_ONNX_MODEL"),
580 std::env::var("GRAPH_STORAGE_ONNX_TOKENIZER"),
581 ) else {
582 assert!(
583 std::env::var("GRAPH_STORAGE_ONNX_REQUIRED").is_err(),
584 "GRAPH_STORAGE_ONNX_REQUIRED is set but the model artifacts are not"
585 );
586 eprintln!("no ONNX artifacts in this environment - skipping");
587 return;
588 };
589 let configured = GraphStorageConfig {
590 embedding_model_path: Some(model),
591 embedding_tokenizer_path: Some(tokenizer),
592 ..with(Some(EmbeddingProviderKind::Onnx))
593 };
594 let provider = select_embedding_provider(&configured)
595 .await
596 .expect("the downloaded artifacts load");
597 assert_eq!(
598 provider.embedding_space().dimension,
599 configured.embedding_dimension
600 );
601 }
602
603 #[cfg(not(feature = "onnx"))]
604 #[tokio::test]
605 async fn onnx_without_the_feature_says_so() {
606 let refused = select_embedding_provider(&with(Some(EmbeddingProviderKind::Onnx)))
607 .await
608 .err()
609 .expect("a provider the binary lacks is refused");
610 assert!(refused.to_string().contains("`onnx` feature"), "{refused}");
611 }
612}