1use std::collections::HashMap;
24use std::time::Duration;
25
26use serde::{Deserialize, Serialize};
27
28use crate::core::{BackendCapability, BackendKind};
29
30use super::analyzer::QueryFeature;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum BackendRole {
43 Primary,
46
47 Search,
50
51 Graph,
54
55 Terminology,
58
59 Archive,
62}
63
64impl BackendRole {
65 pub fn is_primary(&self) -> bool {
67 matches!(self, BackendRole::Primary)
68 }
69
70 pub fn is_secondary(&self) -> bool {
72 !self.is_primary()
73 }
74
75 pub fn typical_capabilities(&self) -> Vec<BackendCapability> {
77 match self {
78 BackendRole::Primary => vec![
79 BackendCapability::Crud,
80 BackendCapability::Versioning,
81 BackendCapability::InstanceHistory,
82 BackendCapability::TypeHistory,
83 BackendCapability::SystemHistory,
84 BackendCapability::BasicSearch,
85 BackendCapability::DateSearch,
86 BackendCapability::ReferenceSearch,
87 BackendCapability::Transactions,
88 BackendCapability::OptimisticLocking,
89 BackendCapability::Include,
90 BackendCapability::Revinclude,
91 BackendCapability::Sorting,
92 BackendCapability::OffsetPagination,
93 BackendCapability::CursorPagination,
94 ],
95 BackendRole::Search => vec![
96 BackendCapability::BasicSearch,
97 BackendCapability::FullTextSearch,
98 BackendCapability::Sorting,
99 ],
100 BackendRole::Graph => vec![
101 BackendCapability::ChainedSearch,
102 BackendCapability::ReverseChaining,
103 BackendCapability::Include,
104 BackendCapability::Revinclude,
105 ],
106 BackendRole::Terminology => vec![BackendCapability::TerminologySearch],
107 BackendRole::Archive => vec![
108 BackendCapability::Crud,
109 BackendCapability::Versioning,
110 BackendCapability::InstanceHistory,
111 ],
112 }
113 }
114}
115
116#[derive(Debug, Clone)]
121pub struct BackendEntry {
122 pub id: String,
124
125 pub role: BackendRole,
127
128 pub kind: BackendKind,
130
131 pub connection: String,
133
134 pub priority: u8,
136
137 pub enabled: bool,
139
140 pub capabilities: Vec<BackendCapability>,
143
144 pub failover_to: Option<String>,
146
147 pub options: HashMap<String, serde_json::Value>,
149}
150
151fn default_priority() -> u8 {
152 100
153}
154
155impl BackendEntry {
156 pub fn new(id: impl Into<String>, role: BackendRole, kind: BackendKind) -> Self {
158 Self {
159 id: id.into(),
160 role,
161 kind,
162 connection: String::new(),
163 priority: default_priority(),
164 enabled: true,
165 capabilities: Vec::new(),
166 failover_to: None,
167 options: HashMap::new(),
168 }
169 }
170
171 pub fn with_connection(mut self, connection: impl Into<String>) -> Self {
173 self.connection = connection.into();
174 self
175 }
176
177 pub fn with_priority(mut self, priority: u8) -> Self {
179 self.priority = priority;
180 self
181 }
182
183 pub fn with_failover(mut self, failover_id: impl Into<String>) -> Self {
185 self.failover_to = Some(failover_id.into());
186 self
187 }
188
189 pub fn with_capabilities(mut self, capabilities: Vec<BackendCapability>) -> Self {
191 self.capabilities = capabilities;
192 self
193 }
194
195 pub fn effective_capabilities(&self) -> Vec<BackendCapability> {
197 if self.capabilities.is_empty() {
198 self.role.typical_capabilities()
199 } else {
200 self.capabilities.clone()
201 }
202 }
203
204 pub fn supports(&self, capability: BackendCapability) -> bool {
206 self.effective_capabilities().contains(&capability)
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct RoutingRule {
213 pub id: String,
215
216 pub triggers: Vec<QueryFeature>,
218
219 pub target_backend: String,
221
222 #[serde(default = "default_priority")]
224 pub priority: u8,
225
226 #[serde(default = "default_fallback")]
228 pub fallback_to_primary: bool,
229}
230
231fn default_fallback() -> bool {
232 true
233}
234
235impl RoutingRule {
236 pub fn new(id: impl Into<String>, target_backend: impl Into<String>) -> Self {
238 Self {
239 id: id.into(),
240 triggers: Vec::new(),
241 target_backend: target_backend.into(),
242 priority: default_priority(),
243 fallback_to_primary: true,
244 }
245 }
246
247 pub fn with_trigger(mut self, feature: QueryFeature) -> Self {
249 self.triggers.push(feature);
250 self
251 }
252
253 pub fn with_triggers(mut self, features: Vec<QueryFeature>) -> Self {
255 self.triggers.extend(features);
256 self
257 }
258
259 pub fn with_priority(mut self, priority: u8) -> Self {
261 self.priority = priority;
262 self
263 }
264
265 pub fn with_fallback(mut self, fallback: bool) -> Self {
267 self.fallback_to_primary = fallback;
268 self
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
274#[serde(rename_all = "lowercase")]
275pub enum SyncMode {
276 Synchronous,
279
280 #[default]
283 Asynchronous,
284
285 Hybrid {
287 sync_for_search: bool,
289 },
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct RetryConfig {
295 #[serde(default = "default_max_retries")]
297 pub max_retries: u32,
298
299 #[serde(with = "humantime_serde", default = "default_initial_delay")]
301 pub initial_delay: Duration,
302
303 #[serde(with = "humantime_serde", default = "default_max_delay")]
305 pub max_delay: Duration,
306
307 #[serde(default = "default_backoff_multiplier")]
309 pub backoff_multiplier: f64,
310}
311
312fn default_max_retries() -> u32 {
313 3
314}
315
316fn default_initial_delay() -> Duration {
317 Duration::from_millis(100)
318}
319
320fn default_max_delay() -> Duration {
321 Duration::from_secs(5)
322}
323
324fn default_backoff_multiplier() -> f64 {
325 2.0
326}
327
328impl Default for RetryConfig {
329 fn default() -> Self {
330 Self {
331 max_retries: default_max_retries(),
332 initial_delay: default_initial_delay(),
333 max_delay: default_max_delay(),
334 backoff_multiplier: default_backoff_multiplier(),
335 }
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct SyncConfig {
342 #[serde(default)]
344 pub mode: SyncMode,
345
346 #[serde(default = "default_max_read_lag")]
349 pub max_read_lag_ms: u64,
350
351 #[serde(default = "default_batch_size")]
353 pub batch_size: usize,
354
355 #[serde(default)]
357 pub retry: RetryConfig,
358}
359
360fn default_max_read_lag() -> u64 {
361 500
362}
363
364fn default_batch_size() -> usize {
365 100
366}
367
368impl Default for SyncConfig {
369 fn default() -> Self {
370 Self {
371 mode: SyncMode::default(),
372 max_read_lag_ms: default_max_read_lag(),
373 batch_size: default_batch_size(),
374 retry: RetryConfig::default(),
375 }
376 }
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct CostWeights {
382 #[serde(default = "default_latency_weight")]
384 pub latency: f64,
385
386 #[serde(default = "default_resource_weight")]
388 pub resource_usage: f64,
389
390 #[serde(default = "default_quality_weight")]
392 pub quality: f64,
393}
394
395fn default_latency_weight() -> f64 {
396 0.5
397}
398
399fn default_resource_weight() -> f64 {
400 0.3
401}
402
403fn default_quality_weight() -> f64 {
404 0.2
405}
406
407impl Default for CostWeights {
408 fn default() -> Self {
409 Self {
410 latency: default_latency_weight(),
411 resource_usage: default_resource_weight(),
412 quality: default_quality_weight(),
413 }
414 }
415}
416
417#[derive(Debug, Clone)]
422pub struct CostConfig {
423 pub base_costs: HashMap<BackendKind, f64>,
426
427 pub feature_multipliers: HashMap<QueryFeature, f64>,
429
430 pub weights: CostWeights,
432}
433
434impl Default for CostConfig {
435 fn default() -> Self {
436 let mut base_costs = HashMap::new();
437 base_costs.insert(BackendKind::Sqlite, 1.0);
440 base_costs.insert(BackendKind::Postgres, 1.2);
441 base_costs.insert(BackendKind::Elasticsearch, 0.8);
442 base_costs.insert(BackendKind::Neo4j, 1.5);
443 base_costs.insert(BackendKind::S3, 2.0);
444
445 let mut feature_multipliers = HashMap::new();
446 feature_multipliers.insert(QueryFeature::BasicSearch, 1.0);
448 feature_multipliers.insert(QueryFeature::ChainedSearch, 3.0);
449 feature_multipliers.insert(QueryFeature::ReverseChaining, 3.5);
450 feature_multipliers.insert(QueryFeature::FullTextSearch, 1.5);
451 feature_multipliers.insert(QueryFeature::TerminologySearch, 2.0);
452
453 Self {
454 base_costs,
455 feature_multipliers,
456 weights: CostWeights::default(),
457 }
458 }
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct HealthConfig {
464 #[serde(with = "humantime_serde", default = "default_health_interval")]
466 pub check_interval: Duration,
467
468 #[serde(with = "humantime_serde", default = "default_health_timeout")]
470 pub timeout: Duration,
471
472 #[serde(default = "default_failure_threshold")]
474 pub failure_threshold: u32,
475
476 #[serde(default = "default_success_threshold")]
478 pub success_threshold: u32,
479}
480
481fn default_health_interval() -> Duration {
482 Duration::from_secs(30)
483}
484
485fn default_health_timeout() -> Duration {
486 Duration::from_secs(5)
487}
488
489fn default_failure_threshold() -> u32 {
490 3
491}
492
493fn default_success_threshold() -> u32 {
494 2
495}
496
497impl Default for HealthConfig {
498 fn default() -> Self {
499 Self {
500 check_interval: default_health_interval(),
501 timeout: default_health_timeout(),
502 failure_threshold: default_failure_threshold(),
503 success_threshold: default_success_threshold(),
504 }
505 }
506}
507
508#[derive(Debug, Clone)]
510pub struct CompositeConfig {
511 pub backends: Vec<BackendEntry>,
513
514 pub routing_rules: Vec<RoutingRule>,
516
517 pub sync_config: SyncConfig,
519
520 pub cost_config: CostConfig,
522
523 pub health_config: HealthConfig,
525}
526
527impl CompositeConfig {
528 pub fn new() -> Self {
530 Self {
531 backends: Vec::new(),
532 routing_rules: Vec::new(),
533 sync_config: SyncConfig::default(),
534 cost_config: CostConfig::default(),
535 health_config: HealthConfig::default(),
536 }
537 }
538
539 pub fn builder() -> CompositeConfigBuilder {
541 CompositeConfigBuilder::new()
542 }
543
544 pub fn primary(&self) -> Option<&BackendEntry> {
546 self.backends
547 .iter()
548 .find(|b| b.role.is_primary() && b.enabled)
549 }
550
551 pub fn primary_id(&self) -> Option<&str> {
553 self.primary().map(|b| b.id.as_str())
554 }
555
556 pub fn secondaries(&self) -> impl Iterator<Item = &BackendEntry> {
558 self.backends
559 .iter()
560 .filter(|b| b.role.is_secondary() && b.enabled)
561 }
562
563 pub fn backend(&self, id: &str) -> Option<&BackendEntry> {
565 self.backends.iter().find(|b| b.id == id)
566 }
567
568 pub fn backends_with_role(&self, role: BackendRole) -> impl Iterator<Item = &BackendEntry> {
570 self.backends
571 .iter()
572 .filter(move |b| b.role == role && b.enabled)
573 }
574
575 pub fn backends_with_capability(
577 &self,
578 capability: BackendCapability,
579 ) -> impl Iterator<Item = &BackendEntry> {
580 self.backends
581 .iter()
582 .filter(move |b| b.enabled && b.supports(capability))
583 }
584
585 pub fn validate(&self) -> Result<Vec<ConfigWarning>, ConfigError> {
587 let mut warnings = Vec::new();
588
589 let primaries: Vec<_> = self
591 .backends
592 .iter()
593 .filter(|b| b.role.is_primary() && b.enabled)
594 .collect();
595
596 if primaries.is_empty() {
597 return Err(ConfigError::NoPrimaryBackend);
598 }
599 if primaries.len() > 1 {
600 return Err(ConfigError::MultiplePrimaryBackends(
601 primaries.iter().map(|b| b.id.clone()).collect(),
602 ));
603 }
604
605 let mut seen_ids = std::collections::HashSet::new();
607 for backend in &self.backends {
608 if !seen_ids.insert(&backend.id) {
609 return Err(ConfigError::DuplicateBackendId(backend.id.clone()));
610 }
611 }
612
613 for backend in &self.backends {
615 if let Some(ref failover_id) = backend.failover_to {
616 if self.backend(failover_id).is_none() {
617 return Err(ConfigError::InvalidFailoverReference {
618 backend_id: backend.id.clone(),
619 failover_id: failover_id.clone(),
620 });
621 }
622 }
623 }
624
625 for rule in &self.routing_rules {
627 if self.backend(&rule.target_backend).is_none() {
628 return Err(ConfigError::InvalidRoutingTarget {
629 rule_id: rule.id.clone(),
630 target_id: rule.target_backend.clone(),
631 });
632 }
633 }
634
635 if self.secondaries().count() == 0 {
637 warnings.push(ConfigWarning::NoSecondaryBackends);
638 }
639
640 let search_backends: Vec<_> = self
642 .backends_with_capability(BackendCapability::FullTextSearch)
643 .collect();
644 if search_backends.len() > 1 {
645 warnings.push(ConfigWarning::RedundantCapability {
646 capability: BackendCapability::FullTextSearch,
647 backends: search_backends.iter().map(|b| b.id.clone()).collect(),
648 });
649 }
650
651 Ok(warnings)
652 }
653}
654
655impl Default for CompositeConfig {
656 fn default() -> Self {
657 Self::new()
658 }
659}
660
661#[derive(Debug, Default)]
663pub struct CompositeConfigBuilder {
664 backends: Vec<BackendEntry>,
665 routing_rules: Vec<RoutingRule>,
666 sync_config: SyncConfig,
667 cost_config: CostConfig,
668 health_config: HealthConfig,
669}
670
671impl CompositeConfigBuilder {
672 pub fn new() -> Self {
674 Self::default()
675 }
676
677 pub fn with_backend(mut self, backend: BackendEntry) -> Self {
679 self.backends.push(backend);
680 self
681 }
682
683 pub fn primary(mut self, id: impl Into<String>, kind: BackendKind) -> Self {
685 self.backends
686 .push(BackendEntry::new(id, BackendRole::Primary, kind));
687 self
688 }
689
690 pub fn search_backend(mut self, id: impl Into<String>, kind: BackendKind) -> Self {
692 self.backends
693 .push(BackendEntry::new(id, BackendRole::Search, kind));
694 self
695 }
696
697 pub fn graph_backend(mut self, id: impl Into<String>, kind: BackendKind) -> Self {
699 self.backends
700 .push(BackendEntry::new(id, BackendRole::Graph, kind));
701 self
702 }
703
704 pub fn terminology_backend(mut self, id: impl Into<String>, kind: BackendKind) -> Self {
706 self.backends
707 .push(BackendEntry::new(id, BackendRole::Terminology, kind));
708 self
709 }
710
711 pub fn with_routing_rule(mut self, rule: RoutingRule) -> Self {
713 self.routing_rules.push(rule);
714 self
715 }
716
717 pub fn sync_mode(mut self, mode: SyncMode) -> Self {
719 self.sync_config.mode = mode;
720 self
721 }
722
723 pub fn with_sync_config(mut self, config: SyncConfig) -> Self {
725 self.sync_config = config;
726 self
727 }
728
729 pub fn with_cost_config(mut self, config: CostConfig) -> Self {
731 self.cost_config = config;
732 self
733 }
734
735 pub fn with_health_config(mut self, config: HealthConfig) -> Self {
737 self.health_config = config;
738 self
739 }
740
741 pub fn build(self) -> Result<CompositeConfig, ConfigError> {
743 let config = CompositeConfig {
744 backends: self.backends,
745 routing_rules: self.routing_rules,
746 sync_config: self.sync_config,
747 cost_config: self.cost_config,
748 health_config: self.health_config,
749 };
750
751 let _ = config.validate()?;
753 Ok(config)
754 }
755
756 pub fn build_with_warnings(self) -> Result<(CompositeConfig, Vec<ConfigWarning>), ConfigError> {
758 let config = CompositeConfig {
759 backends: self.backends,
760 routing_rules: self.routing_rules,
761 sync_config: self.sync_config,
762 cost_config: self.cost_config,
763 health_config: self.health_config,
764 };
765
766 let warnings = config.validate()?;
767 Ok((config, warnings))
768 }
769}
770
771#[derive(Debug, Clone, thiserror::Error)]
773pub enum ConfigError {
774 #[error("no primary backend configured - exactly one primary backend is required")]
776 NoPrimaryBackend,
777
778 #[error("multiple primary backends configured: {0:?} - only one primary is allowed")]
780 MultiplePrimaryBackends(Vec<String>),
781
782 #[error("duplicate backend ID: {0}")]
784 DuplicateBackendId(String),
785
786 #[error("backend '{backend_id}' references non-existent failover backend '{failover_id}'")]
788 InvalidFailoverReference {
789 backend_id: String,
791 failover_id: String,
793 },
794
795 #[error("routing rule '{rule_id}' targets non-existent backend '{target_id}'")]
797 InvalidRoutingTarget {
798 rule_id: String,
800 target_id: String,
802 },
803}
804
805#[derive(Debug, Clone)]
807pub enum ConfigWarning {
808 NoSecondaryBackends,
810
811 RedundantCapability {
813 capability: BackendCapability,
815 backends: Vec<String>,
817 },
818}
819
820impl std::fmt::Display for ConfigWarning {
821 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822 match self {
823 ConfigWarning::NoSecondaryBackends => {
824 write!(
825 f,
826 "no secondary backends configured - using primary for all operations"
827 )
828 }
829 ConfigWarning::RedundantCapability {
830 capability,
831 backends,
832 } => {
833 write!(
834 f,
835 "capability {:?} is provided by multiple backends: {:?}",
836 capability, backends
837 )
838 }
839 }
840 }
841}
842
843mod humantime_serde {
845 use serde::{Deserialize, Deserializer, Serializer};
846 use std::time::Duration;
847
848 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
849 where
850 S: Serializer,
851 {
852 serializer.serialize_str(&humantime::format_duration(*duration).to_string())
853 }
854
855 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
856 where
857 D: Deserializer<'de>,
858 {
859 let s = String::deserialize(deserializer)?;
860 humantime::parse_duration(&s).map_err(serde::de::Error::custom)
861 }
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 #[test]
869 fn test_backend_role_capabilities() {
870 let primary_caps = BackendRole::Primary.typical_capabilities();
871 assert!(primary_caps.contains(&BackendCapability::Crud));
872 assert!(primary_caps.contains(&BackendCapability::Versioning));
873
874 let search_caps = BackendRole::Search.typical_capabilities();
875 assert!(search_caps.contains(&BackendCapability::FullTextSearch));
876
877 let graph_caps = BackendRole::Graph.typical_capabilities();
878 assert!(graph_caps.contains(&BackendCapability::ChainedSearch));
879 }
880
881 #[test]
882 fn test_config_builder_minimal() {
883 let config = CompositeConfigBuilder::new()
884 .primary("sqlite", BackendKind::Sqlite)
885 .build()
886 .unwrap();
887
888 assert_eq!(config.backends.len(), 1);
889 assert!(config.primary().is_some());
890 assert_eq!(config.primary_id(), Some("sqlite"));
891 }
892
893 #[test]
894 fn test_config_builder_with_secondaries() {
895 let config = CompositeConfigBuilder::new()
896 .primary("pg", BackendKind::Postgres)
897 .search_backend("es", BackendKind::Elasticsearch)
898 .graph_backend("neo4j", BackendKind::Neo4j)
899 .build()
900 .unwrap();
901
902 assert_eq!(config.backends.len(), 3);
903 assert_eq!(config.secondaries().count(), 2);
904 }
905
906 #[test]
907 fn test_config_validation_no_primary() {
908 let result = CompositeConfigBuilder::new()
909 .search_backend("es", BackendKind::Elasticsearch)
910 .build();
911
912 assert!(matches!(result, Err(ConfigError::NoPrimaryBackend)));
913 }
914
915 #[test]
916 fn test_config_validation_multiple_primaries() {
917 let result = CompositeConfigBuilder::new()
918 .primary("pg1", BackendKind::Postgres)
919 .primary("pg2", BackendKind::Postgres)
920 .build();
921
922 assert!(matches!(
923 result,
924 Err(ConfigError::MultiplePrimaryBackends(_))
925 ));
926 }
927
928 #[test]
929 fn test_backend_entry_effective_capabilities() {
930 let entry = BackendEntry::new("test", BackendRole::Search, BackendKind::Elasticsearch);
931 let caps = entry.effective_capabilities();
932 assert!(caps.contains(&BackendCapability::FullTextSearch));
933
934 let entry_explicit =
936 BackendEntry::new("test", BackendRole::Search, BackendKind::Elasticsearch)
937 .with_capabilities(vec![BackendCapability::BasicSearch]);
938 let caps_explicit = entry_explicit.effective_capabilities();
939 assert!(caps_explicit.contains(&BackendCapability::BasicSearch));
940 assert!(!caps_explicit.contains(&BackendCapability::FullTextSearch));
941 }
942}