//! Profile activation tracking (Phase 41 PRD). //! //! Phase 41 PRD called out `crates/vectord/src/activation.rs` with //! `ActivationTracker` + background-job pattern. The activation //! handler itself lives in `service.rs::activate_profile` (200+ lines //! of warm-up + bucket binding that's wired to VectorState); this //! module provides the type the PRD named and a single-flight guard //! that satisfies the PRD gate "refuse new activation if one is //! pending/running." //! //! Handler extraction (moving the body of `activate_profile` here) //! is deliberately NOT in this commit — it's a module-structure //! refactor, not a semantic change. When that lands, the inline //! `tokio::spawn` in `service.rs` moves into `ActivationTracker::start` //! and the HTTP handler shrinks to ~20 lines of validate + start + //! respond-202. use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; /// Tracks in-flight profile activations. The PRD's "single-flight guard" /// lives here: callers check `is_pending` before starting a new activation /// and register via `mark_pending` if they proceed. On completion, they /// call `mark_complete` so the next caller can start. /// /// Per-profile granularity — activating profile A doesn't block B. #[derive(Clone, Default)] pub struct ActivationTracker { pending: Arc>>, // profile_id → job_id } impl ActivationTracker { pub fn new() -> Self { Self::default() } /// Check if a profile has an activation already running. Returns the /// in-flight job_id if so. Safe to call without holding a lock. pub async fn is_pending(&self, profile_id: &str) -> Option { self.pending.read().await.get(profile_id).cloned() } /// Register a new activation as pending. Returns false if an /// activation is already running for the same profile (caller should /// return 409 Conflict or surface the existing job_id). Returns true /// on successful registration. pub async fn mark_pending(&self, profile_id: &str, job_id: &str) -> bool { let mut guard = self.pending.write().await; if guard.contains_key(profile_id) { return false; } guard.insert(profile_id.to_string(), job_id.to_string()); true } /// Remove the pending marker when activation finishes (success OR /// failure — both free the slot for the next caller). pub async fn mark_complete(&self, profile_id: &str) { self.pending.write().await.remove(profile_id); } /// How many activations are currently in-flight across all profiles. pub async fn in_flight_count(&self) -> usize { self.pending.read().await.len() } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn empty_tracker_has_no_pending() { let t = ActivationTracker::new(); assert_eq!(t.in_flight_count().await, 0); assert!(t.is_pending("any-profile").await.is_none()); } #[tokio::test] async fn mark_pending_registers_the_job() { let t = ActivationTracker::new(); assert!(t.mark_pending("profile-A", "job-1").await); assert_eq!(t.in_flight_count().await, 1); assert_eq!(t.is_pending("profile-A").await, Some("job-1".into())); } #[tokio::test] async fn single_flight_guard_refuses_second_activation_same_profile() { // PRD Phase 41 gate: "refuse new activation if one is // pending/running." Same profile twice → second call returns // false, caller must surface the in-flight job_id. let t = ActivationTracker::new(); assert!(t.mark_pending("profile-A", "job-1").await); assert!(!t.mark_pending("profile-A", "job-2").await); // Still the first job — second registration didn't overwrite. assert_eq!(t.is_pending("profile-A").await, Some("job-1".into())); } #[tokio::test] async fn different_profiles_dont_block_each_other() { // Per-profile granularity — activating A doesn't block B. let t = ActivationTracker::new(); assert!(t.mark_pending("profile-A", "job-1").await); assert!(t.mark_pending("profile-B", "job-2").await); assert_eq!(t.in_flight_count().await, 2); } #[tokio::test] async fn mark_complete_frees_the_slot() { let t = ActivationTracker::new(); t.mark_pending("profile-A", "job-1").await; t.mark_complete("profile-A").await; assert_eq!(t.in_flight_count().await, 0); // Next activation can now proceed. assert!(t.mark_pending("profile-A", "job-2").await); } }