1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
//! Per-tenant configuration for the ARQ query executor.
//!
//! This module ties together SLA classification ([`oxirs_core::sla::SlaClass`]),
//! query budget hints (timeouts, concurrency), and admission control state for
//! a single logical tenant of the SPARQL endpoint.
//!
//! It is the input feed for [`crate::sla_integration::ArqSlaGate`], which in
//! turn coordinates with [`oxirs_core::sla::AdmissionController`] and
//! [`oxirs_core::sla::PriorityDispatcher`].
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use oxirs_core::sla::{SlaClass, SlaThresholds};
use serde::{Deserialize, Serialize};
// ─────────────────────────────────────────────────────────────────────────────
// TenantConfig
// ─────────────────────────────────────────────────────────────────────────────
/// Configuration assigned to a single SPARQL tenant.
///
/// Each tenant is mapped to exactly one [`SlaClass`] which determines the
/// thresholds applied when admitting and dispatching their queries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TenantConfig {
/// Logical tenant identifier (matches the `X-Tenant-Id` header or similar).
pub tenant_id: String,
/// SLA tier this tenant belongs to.
pub sla_class: SlaClass,
/// Optional human-readable label.
pub label: Option<String>,
/// Whether queries from this tenant may be rejected at admission time.
///
/// When `false`, admission failures are converted to soft warnings rather
/// than errors. Useful for canary tenants and integration tests.
pub strict_admission: bool,
/// Maximum cost factor in token units that a single query may consume.
///
/// Override-able per tenant. Defaults to 1.0 (one token = one query).
pub max_query_cost: f64,
/// Optional per-tenant timeout overriding the global executor timeout.
pub query_timeout: Option<Duration>,
}
impl TenantConfig {
/// Construct a tenant config with sensible defaults.
pub fn new(tenant_id: impl Into<String>, sla_class: SlaClass) -> Self {
Self {
tenant_id: tenant_id.into(),
sla_class,
label: None,
strict_admission: true,
max_query_cost: 1.0,
query_timeout: None,
}
}
/// Attach a human-readable label.
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
/// Override the per-query token cost.
pub fn with_max_query_cost(mut self, cost: f64) -> Self {
self.max_query_cost = cost;
self
}
/// Override the per-query timeout.
pub fn with_query_timeout(mut self, timeout: Duration) -> Self {
self.query_timeout = Some(timeout);
self
}
/// Toggle strict admission.
pub fn with_strict_admission(mut self, strict: bool) -> Self {
self.strict_admission = strict;
self
}
/// Resolve the SLA threshold bundle for this tenant.
pub fn thresholds(&self) -> SlaThresholds {
self.sla_class.thresholds()
}
}
// ─────────────────────────────────────────────────────────────────────────────
// TenantConfigRegistry
// ─────────────────────────────────────────────────────────────────────────────
/// Thread-safe registry of [`TenantConfig`] entries.
///
/// Cheap to clone — backed by an `Arc<RwLock<...>>`. Read-heavy workloads
/// hit the read lock; write operations (register / update / remove) take the
/// write lock.
#[derive(Debug, Clone, Default)]
pub struct TenantConfigRegistry {
inner: Arc<RwLock<HashMap<String, TenantConfig>>>,
}
impl TenantConfigRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self::default()
}
/// Register or replace a tenant config.
pub fn register(&self, config: TenantConfig) {
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
map.insert(config.tenant_id.clone(), config);
}
/// Look up a tenant config (clone returned to avoid lifetime ties).
pub fn get(&self, tenant_id: &str) -> Option<TenantConfig> {
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
map.get(tenant_id).cloned()
}
/// Convenience: fetch the SLA class for a tenant, or `None` if unregistered.
pub fn sla_class(&self, tenant_id: &str) -> Option<SlaClass> {
self.get(tenant_id).map(|c| c.sla_class)
}
/// Remove a tenant. Returns the removed config, if present.
pub fn remove(&self, tenant_id: &str) -> Option<TenantConfig> {
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
map.remove(tenant_id)
}
/// Number of registered tenants.
pub fn len(&self) -> usize {
self.inner.read().unwrap_or_else(|e| e.into_inner()).len()
}
/// Whether the registry is empty.
pub fn is_empty(&self) -> bool {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.is_empty()
}
/// Snapshot all tenant IDs (handy for diagnostics).
pub fn tenant_ids(&self) -> Vec<String> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.keys()
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_tenant_config() {
let cfg = TenantConfig::new("alpha", SlaClass::Gold);
assert_eq!(cfg.tenant_id, "alpha");
assert_eq!(cfg.sla_class, SlaClass::Gold);
assert!(cfg.label.is_none());
assert!(cfg.strict_admission);
assert!((cfg.max_query_cost - 1.0).abs() < 1e-9);
assert!(cfg.query_timeout.is_none());
let thresholds = cfg.thresholds();
assert_eq!(thresholds.max_concurrent_queries, 20);
}
#[test]
fn test_builder_pattern() {
let cfg = TenantConfig::new("beta", SlaClass::Platinum)
.with_label("beta-corp")
.with_max_query_cost(2.5)
.with_query_timeout(Duration::from_millis(500))
.with_strict_admission(false);
assert_eq!(cfg.label.as_deref(), Some("beta-corp"));
assert!((cfg.max_query_cost - 2.5).abs() < 1e-9);
assert_eq!(cfg.query_timeout, Some(Duration::from_millis(500)));
assert!(!cfg.strict_admission);
}
#[test]
fn test_registry_roundtrip() {
let registry = TenantConfigRegistry::new();
assert!(registry.is_empty());
registry.register(TenantConfig::new("gamma", SlaClass::Silver));
assert_eq!(registry.len(), 1);
assert_eq!(registry.sla_class("gamma"), Some(SlaClass::Silver));
assert_eq!(registry.sla_class("delta"), None);
let removed = registry.remove("gamma");
assert!(removed.is_some());
assert!(registry.is_empty());
}
#[test]
fn test_registry_replaces_on_reregister() {
let registry = TenantConfigRegistry::new();
registry.register(TenantConfig::new("epsilon", SlaClass::Bronze));
assert_eq!(registry.sla_class("epsilon"), Some(SlaClass::Bronze));
registry.register(TenantConfig::new("epsilon", SlaClass::Platinum));
assert_eq!(registry.sla_class("epsilon"), Some(SlaClass::Platinum));
assert_eq!(registry.len(), 1);
}
#[test]
fn test_registry_clone_shares_state() {
let r1 = TenantConfigRegistry::new();
let r2 = r1.clone();
r1.register(TenantConfig::new("zeta", SlaClass::Gold));
assert_eq!(r2.sla_class("zeta"), Some(SlaClass::Gold));
}
#[test]
fn test_serde_roundtrip_tenant_config() {
let cfg = TenantConfig::new("eta", SlaClass::Gold).with_label("eta-corp");
let json = serde_json::to_string(&cfg).expect("serialize");
let back: TenantConfig = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.tenant_id, "eta");
assert_eq!(back.sla_class, SlaClass::Gold);
assert_eq!(back.label.as_deref(), Some("eta-corp"));
}
#[test]
fn test_tenant_ids() {
let registry = TenantConfigRegistry::new();
registry.register(TenantConfig::new("a", SlaClass::Bronze));
registry.register(TenantConfig::new("b", SlaClass::Silver));
let mut ids = registry.tenant_ids();
ids.sort();
assert_eq!(ids, vec!["a", "b"]);
}
}