Skip to main content

forge_gateway/
pool.rs

1//! The account pool: the account state map behind one handle.
2//!
3//! Every operation the caller performs on account state goes through a
4//! named method here. The callers never access the inner maps directly,
5//! so the locking discipline is owned by this type and can change
6//! without touching any call site.
7
8use std::collections::{BTreeMap, HashMap};
9use std::time::Duration;
10
11use parking_lot::Mutex;
12
13use crate::Provider;
14
15use crate::UsageSnapshot;
16use crate::account::{
17    AccountKey, AccountStateMap, LoadingState, Unusable, UsageFetchStatus, account_serves,
18};
19
20/// The account state map, behind one handle.
21pub struct AccountPool {
22    accounts: Mutex<AccountStateMap>,
23}
24
25impl AccountPool {
26    pub fn new(specs: &[forge_primitives::account::LoadedAccount]) -> Self {
27        Self { accounts: Mutex::new(AccountStateMap::new(specs)) }
28    }
29
30    /// Empty map for the `testing` feature's `Workspace::testing_stub`.
31    #[cfg(any(test, feature = "testing"))]
32    pub fn empty_for_test() -> Self {
33        Self { accounts: Mutex::new(AccountStateMap::empty_for_test()) }
34    }
35
36    pub fn all_loaded(&self) -> bool {
37        self.accounts.lock().all_loaded()
38    }
39
40    pub fn usage(&self, display_name: &str) -> Option<UsageSnapshot> {
41        self.accounts.lock().usage(&AccountKey(display_name.to_owned())).cloned()
42    }
43
44    /// The newest reading a view draws: a rate-limited response's windows
45    /// when one landed after the last successful probe, else that probe's
46    /// snapshot.
47    pub fn latest_reading(&self, display_name: &str) -> Option<UsageSnapshot> {
48        self.accounts.lock().latest_reading(&AccountKey(display_name.to_owned())).cloned()
49    }
50
51    pub fn usage_error(&self, display_name: &str) -> Option<UsageFetchStatus> {
52        self.accounts.lock().usage_error(&AccountKey(display_name.to_owned()))
53    }
54
55    pub fn auth(&self, display_name: &str) -> Option<forge_primitives::account::AccountAuth> {
56        self.accounts.lock().auth(&AccountKey(display_name.to_owned()))
57    }
58
59    pub fn env(&self, key: &AccountKey) -> Option<HashMap<String, String>> {
60        self.accounts.lock().env(key).cloned()
61    }
62
63    pub fn provider(&self, key: &AccountKey) -> Option<forge_primitives::account::Provider> {
64        self.accounts.lock().provider(key)
65    }
66
67    pub fn loading_state(&self, key: &AccountKey) -> LoadingState {
68        self.accounts.lock().loading_state(key)
69    }
70
71    pub fn is_saturated(&self, key: &AccountKey) -> bool {
72        self.accounts.lock().is_saturated(key)
73    }
74
75    pub fn unusable_reason(&self, key: &AccountKey) -> Option<Unusable> {
76        self.accounts.lock().unusable_reason(key)
77    }
78
79    pub fn scheduler_should_probe(&self, key: &AccountKey) -> bool {
80        self.accounts.lock().scheduler_should_probe(key)
81    }
82
83    pub fn should_probe_now(&self, key: &AccountKey) -> bool {
84        self.accounts.lock().should_probe_now(key)
85    }
86
87    pub fn set_usage(&self, key: &AccountKey, snapshot: UsageSnapshot) {
88        self.accounts.lock().set_usage(key, snapshot);
89    }
90
91    pub fn set_loading(&self, key: &AccountKey, loading: LoadingState) {
92        self.accounts.lock().set_loading(key, loading);
93    }
94
95    pub fn set_limit_reading(&self, key: &AccountKey, snapshot: UsageSnapshot) {
96        self.accounts.lock().set_limit_reading(key, snapshot);
97    }
98
99    pub fn set_last_error(
100        &self,
101        key: &AccountKey,
102        status: UsageFetchStatus,
103        retry_after: Option<Duration>,
104    ) {
105        self.accounts.lock().set_last_error(key, status, retry_after);
106    }
107
108    pub fn clear_last_error(&self, key: &AccountKey) {
109        self.accounts.lock().clear_last_error(key);
110    }
111
112    pub fn disarm_override(&self, key: &AccountKey) {
113        self.accounts.lock().disarm_override(key);
114    }
115
116    pub fn seed_from_cache(&self, cached: &BTreeMap<String, UsageSnapshot>) {
117        self.accounts.lock().seed_from_cache(cached);
118    }
119
120    pub fn snapshots_for_cache(&self) -> BTreeMap<String, UsageSnapshot> {
121        self.accounts.lock().snapshots_for_cache()
122    }
123
124    pub fn account_names(&self) -> Vec<String> {
125        self.accounts.lock().ordered_keys.iter().map(|k| k.0.clone()).collect()
126    }
127
128    /// The account keys in declaration order.
129    pub fn ordered_account_keys(&self) -> Vec<AccountKey> {
130        self.accounts.lock().ordered_keys.clone()
131    }
132
133    /// How long until the account's next scheduled probe, when one is
134    /// pending.
135    pub fn next_probe_after(&self, key: &AccountKey) -> Option<Duration> {
136        self.accounts
137            .lock()
138            .by_key
139            .get(key)
140            .and_then(|s| s.next_probe_at)
141            .and_then(|t| t.checked_duration_since(std::time::Instant::now()))
142    }
143
144    /// The poller's work list: every account due a probe, with its
145    /// provider and env.
146    pub fn probe_entries(&self) -> Vec<(AccountKey, Provider, HashMap<String, String>)> {
147        let state = self.accounts.lock();
148        state
149            .ordered_keys
150            .iter()
151            .filter(|key| state.scheduler_should_probe(key))
152            .filter_map(|key| {
153                let provider = state.provider(key)?;
154                Some((key.clone(), provider, state.env(key).cloned().unwrap_or_default()))
155            })
156            .collect()
157    }
158
159    /// Disarm the one-shot reset-clear override for accounts whose
160    /// backoff is genuinely active.
161    pub fn disarm_backoff_overrides(&self, keys: &[AccountKey]) {
162        let mut state = self.accounts.lock();
163        for key in keys {
164            if !state.should_probe_now(key) {
165                state.disarm_override(key);
166            }
167        }
168    }
169
170    /// Run the declared-model selection walk for one org pin.
171    pub fn select_account(
172        &self,
173        pin: &crate::selection::OrgPin,
174        org: &str,
175        model: &str,
176    ) -> Result<AccountKey, crate::selection::SelectionError> {
177        let state = self.accounts.lock();
178        crate::selection::select_account(&state, pin, org, model)
179    }
180
181    /// `true` when the account serves `model`. An unknown account
182    /// serves nothing.
183    pub fn declares(&self, key: &AccountKey, model: &str) -> bool {
184        self.accounts
185            .lock()
186            .by_key
187            .get(key)
188            .is_some_and(|account| account_serves(&account.models, &account.model_aliases, model))
189    }
190
191    /// The upstream slug the account maps `canonical` to, when the
192    /// account declares a different upstream spelling. `None` forwards
193    /// the canonical name unchanged.
194    pub fn model_slug_for(&self, key: &AccountKey, canonical: &str) -> Option<String> {
195        self.accounts.lock().by_key.get(key)?.model_slugs.get(canonical).cloned()
196    }
197
198    /// Set a slug mapping on one account. Test setup only - production
199    /// slugs arrive through the config-loaded state map.
200    #[cfg(any(test, feature = "testing"))]
201    pub fn set_model_slug(&self, key: &AccountKey, canonical: &str, slug: &str) {
202        if let Some(account) = self.accounts.lock().by_key.get_mut(key) {
203            account.model_slugs.insert(canonical.to_owned(), slug.to_owned());
204        }
205    }
206
207    /// The provider and env of one account, for credential resolution.
208    pub fn provider_and_env(
209        &self,
210        key: &AccountKey,
211    ) -> Option<(Provider, HashMap<String, String>)> {
212        let state = self.accounts.lock();
213        let account = state.by_key.get(key)?;
214        Some((account.provider, account.env.clone()))
215    }
216
217    /// Replace the whole state map. Test fixture setup only.
218    #[cfg(any(test, feature = "testing"))]
219    pub fn replace_state_for_test(&self, map: AccountStateMap) {
220        *self.accounts.lock() = map;
221    }
222
223    /// Replace the account set with one a config reload produced.
224    ///
225    /// Health for an account present in both sets is carried across: a
226    /// reload is not a reason to blank the usage bars, and resetting a
227    /// terminal `LoadingState` would make a ready account unspawnable until
228    /// its next probe. An account new to the set starts `Loading`.
229    pub fn replace_accounts(&self, specs: &[forge_primitives::account::LoadedAccount]) {
230        let mut current = self.accounts.lock();
231        let mut next = AccountStateMap::new(specs);
232        for key in &next.ordered_keys {
233            let Some(previous) = current.by_key.get(key) else { continue };
234            let Some(state) = next.by_key.get_mut(key) else { continue };
235            state.usage.clone_from(&previous.usage);
236            state.last_error.clone_from(&previous.last_error);
237            state.next_probe_at = previous.next_probe_at;
238            state.consecutive_failures = previous.consecutive_failures;
239            state.override_armed = previous.override_armed;
240            state.loading = previous.loading;
241        }
242        // An account the edit removed is retired rather than dropped: a
243        // session already bound to it keeps resolving its provider and env,
244        // while `select_account` skips a retired entry so no new selection
245        // can pick it.
246        for (key, previous) in &current.by_key {
247            if next.by_key.contains_key(key) {
248                continue;
249            }
250            let mut retired = previous.clone();
251            retired.retired = true;
252            next.by_key.insert(key.clone(), retired);
253        }
254        *current = next;
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use std::collections::HashMap;
261    use std::time::SystemTime;
262
263    use forge_primitives::account::{LoadedAccount, Provider};
264    use forge_primitives::usage::{UsageSnapshot, UsageSourceKind};
265
266    use super::AccountPool;
267    use crate::account::AccountKey;
268
269    fn account(name: &str) -> LoadedAccount {
270        LoadedAccount {
271            display_name: name.to_owned(),
272            provider: Provider::Anthropic,
273            base_url: None,
274            models: vec!["test-model".to_owned()],
275            model_slugs: HashMap::new(),
276            model_aliases: HashMap::new(),
277            env: HashMap::new(),
278        }
279    }
280
281    fn snapshot() -> UsageSnapshot {
282        UsageSnapshot {
283            source: UsageSourceKind::Oauth,
284            fetched_at: SystemTime::UNIX_EPOCH,
285            five_hour: None,
286            seven_day: None,
287            seven_day_opus: None,
288            seven_day_sonnet: None,
289            extra_usage: None,
290            spend: None,
291            balance: None,
292        }
293    }
294
295    /// A reload rebuilds the map; an account in both sets keeps its reading
296    /// and its terminal loading state, so the edit does not blank the bars
297    /// or refuse spawns until the next probe.
298    #[test]
299    fn a_reload_carries_surviving_accounts_health_across() {
300        let pool = AccountPool::new(&[account("Kept"), account("Gone")]);
301        let kept = AccountKey("Kept".to_owned());
302        let gone = AccountKey("Gone".to_owned());
303        pool.set_usage(&kept, snapshot());
304        pool.set_loading(&kept, crate::LoadingState::Ready);
305
306        pool.replace_accounts(&[account("Kept"), account("Added")]);
307
308        assert!(pool.usage("Kept").is_some(), "the surviving account keeps its usage reading");
309        assert_eq!(
310            pool.loading_state(&kept),
311            crate::LoadingState::Ready,
312            "the surviving account stays ready, so a spawn is not refused after an edit",
313        );
314        assert_eq!(
315            pool.loading_state(&AccountKey("Added".to_owned())),
316            crate::LoadingState::Loading,
317            "an account the reload added waits for its first probe",
318        );
319        assert!(
320            pool.provider_and_env(&gone).is_some(),
321            "a bound session still resolves a removed account's provider and env",
322        );
323    }
324
325    /// The reload window a guard cannot close: a caller holding the old pin
326    /// (which still names the account) selects against the new pool. The
327    /// retired entry must not answer, or a brand-new session binds to an
328    /// account the edit removed.
329    #[test]
330    fn a_retired_account_is_never_selected() {
331        let pool = AccountPool::new(&[account("Kept"), account("Gone")]);
332        let gone = AccountKey("Gone".to_owned());
333        pool.set_loading(&gone, crate::LoadingState::Ready);
334        pool.replace_accounts(&[account("Kept")]);
335
336        let pin = crate::selection::OrgPin {
337            accounts: vec!["Gone".to_owned()],
338            fallback_accounts: Vec::new(),
339        };
340        let picked =
341            crate::selection::select_account(&pool.accounts.lock(), &pin, "Org", "test-model");
342
343        assert_eq!(
344            picked,
345            Err(crate::selection::SelectionError::NoEligibleAccount {
346                model: "test-model".to_owned(),
347                org: "Org".to_owned(),
348            }),
349            "a retired account is not offered to a new selection",
350        );
351    }
352
353    /// An account retired mid-probe never reaches a terminal loading state,
354    /// so the loaded gate must read the retirement itself as settled. Without
355    /// it `all_loaded` stays false for the process lifetime and every cron
356    /// fire stays deferred.
357    #[test]
358    fn a_retired_loading_account_does_not_hold_the_loaded_gate_closed() {
359        let pool = AccountPool::new(&[account("Kept"), account("Gone")]);
360        let kept = AccountKey("Kept".to_owned());
361        pool.set_loading(&kept, crate::LoadingState::Ready);
362        assert_eq!(
363            pool.loading_state(&AccountKey("Gone".to_owned())),
364            crate::LoadingState::Loading,
365            "premise: the removed account is retired while still loading",
366        );
367
368        pool.replace_accounts(&[account("Kept")]);
369
370        assert!(
371            pool.all_loaded(),
372            "a retired account can never load, so it must not keep the gate closed",
373        );
374    }
375}