Three PRD gaps closed in one coherent batch — all were cosmetic or
scaffold-shaped, now real files:
Phase 39 (PRD:57):
+ config/providers.toml — provider registry (name/base_url/auth/
default_model) for ollama, ollama_cloud, openrouter. Commented
stubs for gemini + claude pending adapter work. Secrets stay in
/etc/lakehouse/secrets.toml or env, NEVER inline.
Phase 41 (PRD:115):
+ crates/vectord/src/activation.rs — ActivationTracker with the
PRD-named single-flight guard ("refuse new activation if one is
pending/running"). Per-profile granularity — activating A doesn't
block B. 5 tests cover the full state machine. Handler body stays
in service.rs for now; tracker usage integration is a follow-up.
Phase 41 (PRD:113):
+ crates/shared/src/profiles/ with 4 submodules:
* execution.rs — `pub use crate::types::ModelProfile as
ExecutionProfile` (backward-compat rename per PRD)
* retrieval.rs — top_k, rerank_top_k, freshness cutoff,
playbook boost, sensitivity-gate enforcement
* memory.rs — playbook boost ceiling, history cap, doc
staleness, auto-retire-on-failure
* observer.rs — failure cluster size, alert cooldown, ring
size, langfuse forwarding
All fields `#[serde(default)]` so existing ModelProfile files
load unchanged.
Still open from the same phases:
- Gemini + Claude provider adapters (Phase 40 — 100-200 LOC each)
- Full activate_profile handler extraction into activation.rs
(Phase 41 — module-structure refactor)
- Catalogd CRUD endpoints for retrieval/memory/observer profiles
(Phase 41 — exists at list level, no create/update/delete yet)
- truth/ repo-root directory for file-backed rules (Phase 42 —
TOML loader + schema)
- crates/validator crate (Phase 43 — full greenfield)
Workspace warnings still at 0. 5 new tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
4.6 KiB
Rust
119 lines
4.6 KiB
Rust
//! 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<RwLock<HashMap<String, String>>>, // 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<String> {
|
|
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);
|
|
}
|
|
}
|