1use bhtune_db::SqlitePool;
4use chrono::{DateTime, Duration, Utc};
5use std::collections::{HashMap, VecDeque};
6use std::sync::{Arc, RwLock};
7
8use crate::active_run::ActiveRun;
9use bhtune_runtime::config::{DemoPolicy, ServerMode};
10use tokio::sync::Mutex;
11use tokio::sync::{OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct DemoQuotaExceeded {
15 pub retry_after_secs: u64,
16}
17
18#[derive(Debug, Clone)]
19struct AcceptedStart {
20 id: u64,
21 accepted_at: DateTime<Utc>,
22}
23
24#[derive(Default)]
25struct AcceptedStartWindows {
26 next_id: u64,
27 by_token: HashMap<String, VecDeque<AcceptedStart>>,
28 by_ip: HashMap<String, VecDeque<AcceptedStart>>,
29}
30
31#[derive(Debug)]
32pub struct DemoAcceptedStart {
33 id: u64,
34 token_hash: String,
35 client_ip: String,
36}
37
38#[derive(Clone)]
39pub struct DemoRuntime {
40 run_permits: Arc<Semaphore>,
41 sse_permits: Arc<Semaphore>,
42 ordinary_request_permits: Arc<Semaphore>,
43 start_admission: Arc<Mutex<()>>,
44 accepted_starts: Arc<Mutex<AcceptedStartWindows>>,
45 visitor_run_permits: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
46 visitor_sse_permits: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
47}
48
49impl DemoRuntime {
50 fn new(policy: DemoPolicy) -> Self {
51 Self {
52 run_permits: Arc::new(Semaphore::new(policy.max_active_runs_global as usize)),
53 sse_permits: Arc::new(Semaphore::new(policy.max_sse_global as usize)),
54 ordinary_request_permits: Arc::new(Semaphore::new(
55 policy.ordinary_request_concurrency as usize,
56 )),
57 start_admission: Arc::new(Mutex::new(())),
58 accepted_starts: Arc::new(Mutex::new(AcceptedStartWindows::default())),
59 visitor_run_permits: Arc::new(Mutex::new(HashMap::new())),
60 visitor_sse_permits: Arc::new(Mutex::new(HashMap::new())),
61 }
62 }
63
64 pub fn try_acquire_global_run(
65 &self,
66 ) -> Result<OwnedSemaphorePermit, tokio::sync::TryAcquireError> {
67 self.run_permits.clone().try_acquire_owned()
68 }
69
70 pub fn try_acquire_global_sse(
71 &self,
72 ) -> Result<OwnedSemaphorePermit, tokio::sync::TryAcquireError> {
73 self.sse_permits.clone().try_acquire_owned()
74 }
75
76 pub fn try_acquire_ordinary_request(
77 &self,
78 ) -> Result<OwnedSemaphorePermit, tokio::sync::TryAcquireError> {
79 self.ordinary_request_permits.clone().try_acquire_owned()
80 }
81
82 pub async fn lock_start_admission(&self) -> OwnedMutexGuard<()> {
86 self.start_admission.clone().lock_owned().await
87 }
88
89 pub async fn try_acquire_visitor_run(
90 &self,
91 token_hash: &str,
92 limit: u32,
93 ) -> Result<OwnedSemaphorePermit, tokio::sync::TryAcquireError> {
94 visitor_semaphore(&self.visitor_run_permits, token_hash, limit)
95 .await
96 .try_acquire_owned()
97 }
98
99 pub async fn try_acquire_visitor_sse(
100 &self,
101 token_hash: &str,
102 limit: u32,
103 ) -> Result<OwnedSemaphorePermit, tokio::sync::TryAcquireError> {
104 visitor_semaphore(&self.visitor_sse_permits, token_hash, limit)
105 .await
106 .try_acquire_owned()
107 }
108
109 pub async fn reserve_accepted_start(
113 &self,
114 token_hash: &str,
115 client_ip: &str,
116 now: DateTime<Utc>,
117 policy: DemoPolicy,
118 ) -> Result<DemoAcceptedStart, DemoQuotaExceeded> {
119 let mut windows = self.accepted_starts.lock().await;
120 prune_start_windows(&mut windows, now, policy.accepted_start_window_secs);
121 if let Some(retry_after_secs) =
122 quota_retry_after(&windows, token_hash, client_ip, now, policy)
123 {
124 return Err(DemoQuotaExceeded { retry_after_secs });
125 }
126
127 windows.next_id = windows.next_id.wrapping_add(1);
128 let accepted = AcceptedStart {
129 id: windows.next_id,
130 accepted_at: now,
131 };
132 windows
133 .by_token
134 .entry(token_hash.to_owned())
135 .or_default()
136 .push_back(accepted.clone());
137 windows
138 .by_ip
139 .entry(client_ip.to_owned())
140 .or_default()
141 .push_back(accepted.clone());
142 Ok(DemoAcceptedStart {
143 id: accepted.id,
144 token_hash: token_hash.to_owned(),
145 client_ip: client_ip.to_owned(),
146 })
147 }
148
149 pub async fn release_accepted_start(&self, accepted: DemoAcceptedStart) {
150 let mut windows = self.accepted_starts.lock().await;
151 remove_accepted_start(&mut windows.by_token, &accepted.token_hash, accepted.id);
152 remove_accepted_start(&mut windows.by_ip, &accepted.client_ip, accepted.id);
153 }
154
155 pub async fn cleanup(&self, now: DateTime<Utc>, policy: DemoPolicy) {
158 {
159 let mut accepted_starts = self.accepted_starts.lock().await;
160 prune_start_windows(&mut accepted_starts, now, policy.accepted_start_window_secs);
161 }
162 {
163 let mut visitor_runs = self.visitor_run_permits.lock().await;
164 retain_active_visitor_semaphores(&mut visitor_runs, policy.max_active_runs_per_visitor);
165 }
166 {
167 let mut visitor_streams = self.visitor_sse_permits.lock().await;
168 retain_active_visitor_semaphores(&mut visitor_streams, policy.max_sse_per_visitor);
169 }
170 }
171}
172
173async fn visitor_semaphore(
174 permits: &Mutex<HashMap<String, Arc<Semaphore>>>,
175 token_hash: &str,
176 limit: u32,
177) -> Arc<Semaphore> {
178 let mut permits = permits.lock().await;
179 permits.retain(|_, permit| {
180 Arc::strong_count(permit) > 1 || permit.available_permits() < limit as usize
181 });
182 permits
183 .entry(token_hash.to_owned())
184 .or_insert_with(|| Arc::new(Semaphore::new(limit as usize)))
185 .clone()
186}
187
188fn retain_active_visitor_semaphores(permits: &mut HashMap<String, Arc<Semaphore>>, limit: u32) {
189 permits.retain(|_, permit| {
190 Arc::strong_count(permit) > 1 || permit.available_permits() < limit as usize
191 });
192}
193
194fn prune_start_windows(windows: &mut AcceptedStartWindows, now: DateTime<Utc>, window_secs: u64) {
195 let cutoff = now - Duration::seconds(window_secs as i64);
196 windows.by_token.retain(|_, starts| {
197 while starts
198 .front()
199 .is_some_and(|start| start.accepted_at <= cutoff)
200 {
201 starts.pop_front();
202 }
203 !starts.is_empty()
204 });
205 windows.by_ip.retain(|_, starts| {
206 while starts
207 .front()
208 .is_some_and(|start| start.accepted_at <= cutoff)
209 {
210 starts.pop_front();
211 }
212 !starts.is_empty()
213 });
214}
215
216fn quota_retry_after(
217 windows: &AcceptedStartWindows,
218 token_hash: &str,
219 client_ip: &str,
220 now: DateTime<Utc>,
221 policy: DemoPolicy,
222) -> Option<u64> {
223 let token_retry = retry_after_for(
224 windows.by_token.get(token_hash),
225 policy.accepted_starts_per_token,
226 now,
227 policy.accepted_start_window_secs,
228 );
229 let ip_retry = retry_after_for(
230 windows.by_ip.get(client_ip),
231 policy.accepted_starts_per_client_ip,
232 now,
233 policy.accepted_start_window_secs,
234 );
235 match (token_retry, ip_retry) {
236 (Some(left), Some(right)) => Some(left.max(right)),
237 (Some(retry), None) | (None, Some(retry)) => Some(retry),
238 (None, None) => None,
239 }
240}
241
242fn retry_after_for(
243 starts: Option<&VecDeque<AcceptedStart>>,
244 limit: u32,
245 now: DateTime<Utc>,
246 window_secs: u64,
247) -> Option<u64> {
248 let starts = starts?;
249 if starts.len() < limit as usize {
250 return None;
251 }
252 let remaining_ms = (starts.front()?.accepted_at + Duration::seconds(window_secs as i64) - now)
253 .num_milliseconds()
254 .max(1);
255 Some((remaining_ms as u64).div_ceil(1_000))
256}
257
258fn remove_accepted_start(
259 windows: &mut HashMap<String, VecDeque<AcceptedStart>>,
260 key: &str,
261 id: u64,
262) {
263 let Some(starts) = windows.get_mut(key) else {
264 return;
265 };
266 starts.retain(|start| start.id != id);
267 if starts.is_empty() {
268 windows.remove(key);
269 }
270}
271
272#[derive(Clone)]
276pub struct AppState {
277 pub pool: SqlitePool,
278 pub(crate) preflight_database: Option<SqlitePool>,
280 pub active_run: ActiveRun,
283 pub config_store: Arc<RwLock<bhtune_runtime::config::LoadedConfigStore>>,
286 pub allowed_origin: Option<String>,
287 pub trusted_proxy: Option<String>,
288 pub mode: ServerMode,
289 pub demo_policy: DemoPolicy,
290 pub demo_runtime: DemoRuntime,
291}
292
293impl AppState {
294 pub fn config_snapshot(&self) -> anyhow::Result<bhtune_runtime::config::BhtuneConfig> {
295 self.config_store
296 .read()
297 .map(|store| store.config.clone())
298 .map_err(|_| anyhow::anyhow!("configuration store lock is poisoned"))
299 }
300
301 pub fn for_mode(
302 pool: SqlitePool,
303 config_store: Arc<RwLock<bhtune_runtime::config::LoadedConfigStore>>,
304 mode: ServerMode,
305 demo_policy: DemoPolicy,
306 ) -> Self {
307 Self::for_mode_with_network_config(pool, config_store, mode, demo_policy, None, None)
308 }
309
310 pub fn for_mode_with_network_config(
311 pool: SqlitePool,
312 config_store: Arc<RwLock<bhtune_runtime::config::LoadedConfigStore>>,
313 mode: ServerMode,
314 demo_policy: DemoPolicy,
315 allowed_origin: Option<String>,
316 trusted_proxy: Option<String>,
317 ) -> Self {
318 Self {
319 pool,
320 preflight_database: None,
321 active_run: ActiveRun::default(),
322 config_store,
323 allowed_origin,
324 trusted_proxy,
325 demo_runtime: DemoRuntime::new(demo_policy),
326 mode,
327 demo_policy,
328 }
329 }
330
331 pub(crate) fn with_preflight_database(
332 mut self,
333 preflight_database: Option<SqlitePool>,
334 ) -> Self {
335 self.preflight_database = preflight_database;
336 self
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[tokio::test]
345 async fn config_snapshot_returns_an_independent_config_copy() {
346 let state = crate::test_support::in_memory_state().await;
347 let mut snapshot = state.config_snapshot().unwrap();
348 snapshot.allow_uncertain_quality = false;
349
350 assert!(state.config_snapshot().unwrap().allow_uncertain_quality);
351 }
352
353 #[tokio::test]
354 async fn config_snapshot_reports_a_poisoned_store_lock() {
355 let state = crate::test_support::in_memory_state().await;
356 let store = state.config_store.clone();
357 let _ = std::thread::spawn(move || {
358 let _guard = store.write().unwrap();
359 panic!("deliberately poison the test lock");
360 })
361 .join();
362
363 assert!(
364 state
365 .config_snapshot()
366 .unwrap_err()
367 .to_string()
368 .contains("poisoned")
369 );
370 }
371
372 #[tokio::test]
373 async fn demo_runtime_uses_the_global_and_ordinary_policy_limits() {
374 let policy = DemoPolicy {
375 max_active_runs_global: 1,
376 max_sse_global: 1,
377 ordinary_request_concurrency: 1,
378 ..DemoPolicy::default()
379 };
380 let runtime = DemoRuntime::new(policy);
381
382 let _run = runtime.try_acquire_global_run().unwrap();
383 assert!(runtime.try_acquire_global_run().is_err());
384 let _sse = runtime.try_acquire_global_sse().unwrap();
385 assert!(runtime.try_acquire_global_sse().is_err());
386 let _request = runtime.try_acquire_ordinary_request().unwrap();
387 assert!(runtime.try_acquire_ordinary_request().is_err());
388 }
389
390 #[tokio::test]
391 async fn demo_start_admission_is_serialized() {
392 let runtime = DemoRuntime::new(DemoPolicy::default());
393 let admission = runtime.lock_start_admission().await;
394 let waiting_runtime = runtime.clone();
395 let waiter = tokio::spawn(async move {
396 let _admission = waiting_runtime.lock_start_admission().await;
397 });
398 tokio::task::yield_now().await;
399 assert!(!waiter.is_finished());
400
401 drop(admission);
402
403 waiter.await.unwrap();
404 }
405
406 #[tokio::test]
407 async fn accepted_start_quotas_are_independent_for_token_and_client_ip() {
408 let policy = DemoPolicy::default();
409 let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
410 let token_runtime = DemoRuntime::new(policy);
411 for index in 0..policy.accepted_starts_per_token {
412 token_runtime
413 .reserve_accepted_start("token", &format!("ip-{index}"), now, policy)
414 .await
415 .unwrap();
416 }
417 let token_error = token_runtime
418 .reserve_accepted_start("token", "unused-ip", now, policy)
419 .await
420 .unwrap_err();
421 assert_eq!(
422 token_error.retry_after_secs,
423 policy.accepted_start_window_secs
424 );
425 assert!(
426 token_runtime
427 .reserve_accepted_start(
428 "token",
429 "unused-ip",
430 now + Duration::seconds(policy.accepted_start_window_secs as i64),
431 policy,
432 )
433 .await
434 .is_ok()
435 );
436
437 let ip_runtime = DemoRuntime::new(policy);
438 for index in 0..policy.accepted_starts_per_client_ip {
439 ip_runtime
440 .reserve_accepted_start(&format!("token-{index}"), "shared-ip", now, policy)
441 .await
442 .unwrap();
443 }
444 assert!(
445 ip_runtime
446 .reserve_accepted_start("unused-token", "shared-ip", now, policy)
447 .await
448 .is_err()
449 );
450 assert!(
451 ip_runtime
452 .reserve_accepted_start("unused-token", "unused-ip", now, policy)
453 .await
454 .is_ok()
455 );
456 }
457
458 #[tokio::test]
459 async fn accepted_start_quota_uses_the_larger_token_or_ip_retry_after() {
460 let policy = DemoPolicy {
461 accepted_starts_per_token: 1,
462 accepted_starts_per_client_ip: 1,
463 accepted_start_window_secs: 20,
464 ..DemoPolicy::default()
465 };
466 let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
467 let runtime = DemoRuntime::new(policy);
468
469 runtime
470 .reserve_accepted_start("token", "other-ip", now - Duration::seconds(5), policy)
471 .await
472 .unwrap();
473 runtime
474 .reserve_accepted_start(
475 "other-token",
476 "shared-ip",
477 now - Duration::seconds(10),
478 policy,
479 )
480 .await
481 .unwrap();
482
483 let error = runtime
484 .reserve_accepted_start("token", "shared-ip", now, policy)
485 .await
486 .unwrap_err();
487
488 assert_eq!(error.retry_after_secs, 15);
489 }
490
491 #[tokio::test]
492 async fn rejected_start_reservations_can_be_released_without_consuming_quota() {
493 let policy = DemoPolicy::default();
494 let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
495 let runtime = DemoRuntime::new(policy);
496 let mut starts = Vec::new();
497 for index in 0..policy.accepted_starts_per_token {
498 starts.push(
499 runtime
500 .reserve_accepted_start("token", &format!("ip-{index}"), now, policy)
501 .await
502 .unwrap(),
503 );
504 }
505 assert!(
506 runtime
507 .reserve_accepted_start("token", "another-ip", now, policy)
508 .await
509 .is_err()
510 );
511
512 runtime.release_accepted_start(starts.remove(0)).await;
513
514 assert!(
515 runtime
516 .reserve_accepted_start("token", "another-ip", now, policy)
517 .await
518 .is_ok()
519 );
520 }
521
522 #[tokio::test]
523 async fn releasing_an_accepted_start_removes_empty_token_and_ip_windows() {
524 let policy = DemoPolicy::default();
525 let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
526 let runtime = DemoRuntime::new(policy);
527 let accepted = runtime
528 .reserve_accepted_start("token", "ip", now, policy)
529 .await
530 .unwrap();
531
532 runtime.release_accepted_start(accepted).await;
533
534 let windows = runtime.accepted_starts.lock().await;
535 assert!(windows.by_token.is_empty());
536 assert!(windows.by_ip.is_empty());
537 }
538
539 #[test]
540 fn removing_an_unknown_accepted_start_is_a_no_op() {
541 let mut windows = HashMap::new();
542
543 remove_accepted_start(&mut windows, "missing", 1);
544
545 assert!(windows.is_empty());
546 }
547
548 #[tokio::test]
549 async fn cleanup_reclaims_expired_windows_and_idle_visitor_semaphores() {
550 let policy = DemoPolicy {
551 accepted_starts_per_token: 1,
552 accepted_starts_per_client_ip: 1,
553 accepted_start_window_secs: 10,
554 max_active_runs_per_visitor: 1,
555 max_sse_per_visitor: 1,
556 ..DemoPolicy::default()
557 };
558 let runtime = DemoRuntime::new(policy);
559 let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
560 runtime
561 .reserve_accepted_start("token", "ip", now, policy)
562 .await
563 .unwrap();
564 drop(runtime.try_acquire_visitor_run("token", 1).await.unwrap());
565 drop(runtime.try_acquire_visitor_sse("token", 1).await.unwrap());
566
567 runtime.cleanup(now + Duration::seconds(10), policy).await;
568
569 assert!(
570 runtime
571 .reserve_accepted_start("token", "ip", now + Duration::seconds(10), policy)
572 .await
573 .is_ok()
574 );
575 assert!(runtime.visitor_run_permits.lock().await.is_empty());
576 assert!(runtime.visitor_sse_permits.lock().await.is_empty());
577 }
578}