Skip to main content

xet_runtime/core/
common.rs

1use std::any::Any;
2use std::collections::HashMap;
3use std::sync::atomic::AtomicU64;
4use std::sync::{Arc, Mutex};
5
6use reqwest::Client;
7use tokio::sync::Semaphore;
8
9use crate::config::XetConfig;
10use crate::utils::adjustable_semaphore::AdjustableSemaphore;
11
12/// Holds shared state that is common across the entire context.
13///
14/// Accessible via `ctx.common` on a [`super::XetContext`].
15pub struct XetCommon {
16    /// Limits the number of files being ingested (cleaned/uploaded) concurrently.
17    pub file_ingestion_semaphore: Arc<Semaphore>,
18
19    /// Limits the number of files being downloaded concurrently.
20    pub file_download_semaphore: Arc<Semaphore>,
21
22    /// Limits total memory used for buffering data during reconstruction downloads.
23    pub reconstruction_download_buffer: Arc<AdjustableSemaphore>,
24
25    /// Tracks the number of currently active file downloads for dynamic buffer scaling.
26    pub active_downloads: Arc<AtomicU64>,
27
28    /// Type-erased cache for runtime-scoped resources. Subsystems store their own
29    /// caches here (keyed by a unique string) instead of using process-global statics,
30    /// so everything is cleaned up when the runtime drops.
31    runtime_cache: Mutex<HashMap<String, Box<dyn Any + Send + Sync>>>,
32}
33
34impl XetCommon {
35    /// Creates a new `XetCommon` instance with the given configuration.
36    pub fn new(config: &XetConfig) -> Self {
37        Self {
38            file_ingestion_semaphore: Arc::new(Semaphore::new(config.data.max_concurrent_file_ingestion)),
39            file_download_semaphore: Arc::new(Semaphore::new(config.data.max_concurrent_file_downloads)),
40            reconstruction_download_buffer: {
41                let base = config.reconstruction.download_buffer_size.as_u64();
42                let limit = config.reconstruction.download_buffer_limit.as_u64();
43                AdjustableSemaphore::new(base, (base, limit))
44            },
45            active_downloads: Arc::new(AtomicU64::new(0)),
46            runtime_cache: Mutex::new(HashMap::new()),
47        }
48    }
49
50    /// Retrieves a cached value by key, or creates and stores it using `create`.
51    ///
52    /// Values are stored as type-erased `Box<dyn Any>` and recovered via `downcast_ref`.
53    /// Typical stored types are `Arc<Mutex<...>>` or `Arc<RwLock<...>>`, making
54    /// the clone cheap (just an Arc bump).
55    pub fn cache_get_or_create<T, F>(&self, key: &str, create: F) -> T
56    where
57        T: Clone + Send + Sync + 'static,
58        F: FnOnce() -> T,
59    {
60        let mut guard = self.runtime_cache.lock().unwrap();
61        if let Some(existing) = guard.get(key)
62            && let Some(val) = existing.downcast_ref::<T>()
63        {
64            return val.clone();
65        }
66        let value = create();
67        guard.insert(key.to_string(), Box::new(value.clone()));
68        value
69    }
70
71    /// Gets or creates a reqwest client, using a tag to identify the client type.
72    ///
73    /// # Arguments
74    /// * `tag` - A string identifier for the client (e.g., "tcp" for regular, socket path for UDS)
75    /// * `create_client_fn` - A function that creates the client if needed
76    ///
77    /// # Returns
78    /// Returns a clone of the cached client if the tag matches, or creates a new client if the tag differs.
79    pub fn get_or_create_reqwest_client<F>(&self, tag: String, create_client_fn: F) -> crate::error::Result<Client>
80    where
81        F: FnOnce() -> std::result::Result<Client, reqwest::Error>,
82    {
83        let client_cache: Arc<Mutex<Option<(String, Client)>>> =
84            self.cache_get_or_create("global_reqwest_client", || Arc::new(Mutex::new(None)));
85        let mut guard = client_cache.lock()?;
86
87        match guard.as_ref() {
88            Some((cached_tag, cached_client)) if cached_tag == &tag => Ok(cached_client.clone()),
89            _ => {
90                let new_client = create_client_fn()?;
91                *guard = Some((tag, new_client.clone()));
92                Ok(new_client)
93            },
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use std::sync::atomic::{AtomicUsize, Ordering};
101
102    use super::*;
103
104    #[test]
105    fn test_get_or_create_reqwest_client_caches_by_tag() {
106        let common = XetCommon::new(&XetConfig::new());
107        let call_count = AtomicUsize::new(0);
108
109        let _client1 = common
110            .get_or_create_reqwest_client("test-tag".to_string(), || {
111                call_count.fetch_add(1, Ordering::SeqCst);
112                reqwest::Client::builder().build()
113            })
114            .unwrap();
115
116        let _client2 = common
117            .get_or_create_reqwest_client("test-tag".to_string(), || {
118                call_count.fetch_add(1, Ordering::SeqCst);
119                reqwest::Client::builder().build()
120            })
121            .unwrap();
122
123        assert_eq!(call_count.load(Ordering::SeqCst), 1);
124    }
125
126    #[test]
127    fn test_get_or_create_reqwest_client_creates_new_for_different_tag() {
128        let common = XetCommon::new(&XetConfig::new());
129        let call_count = AtomicUsize::new(0);
130
131        let _client1 = common
132            .get_or_create_reqwest_client("tag1".to_string(), || {
133                call_count.fetch_add(1, Ordering::SeqCst);
134                reqwest::Client::builder().user_agent("client1").build()
135            })
136            .unwrap();
137
138        let _client2 = common
139            .get_or_create_reqwest_client("tag2".to_string(), || {
140                call_count.fetch_add(1, Ordering::SeqCst);
141                reqwest::Client::builder().user_agent("client2").build()
142            })
143            .unwrap();
144
145        assert_eq!(call_count.load(Ordering::SeqCst), 2);
146    }
147
148    #[test]
149    fn test_replaces_client_when_tag_changes() {
150        let common = XetCommon::new(&XetConfig::new());
151
152        let _client1 = common
153            .get_or_create_reqwest_client("tcp".to_string(), || {
154                reqwest::Client::builder().user_agent("tcp-client").build()
155            })
156            .unwrap();
157
158        let _client2 = common
159            .get_or_create_reqwest_client("/tmp/socket.sock".to_string(), || {
160                reqwest::Client::builder().user_agent("uds-client").build()
161            })
162            .unwrap();
163
164        // Second call with a different tag should have triggered creation again;
165        // verify by calling with the new tag and confirming no creation happens.
166        let call_count = AtomicUsize::new(0);
167        let _client3 = common
168            .get_or_create_reqwest_client("/tmp/socket.sock".to_string(), || {
169                call_count.fetch_add(1, Ordering::SeqCst);
170                reqwest::Client::builder().build()
171            })
172            .unwrap();
173        assert_eq!(call_count.load(Ordering::SeqCst), 0);
174    }
175
176    #[test]
177    fn test_semaphores_initialized_from_config() {
178        let config = XetConfig::new();
179        let common = XetCommon::new(&config);
180
181        assert_eq!(common.file_ingestion_semaphore.available_permits(), config.data.max_concurrent_file_ingestion);
182        assert_eq!(common.file_download_semaphore.available_permits(), config.data.max_concurrent_file_downloads);
183
184        // Total permits is at least the configured download_buffer_base (may be slightly
185        // larger due to rounding up to a whole number of internal permits).
186        assert!(
187            common.reconstruction_download_buffer.total_permits()
188                >= config.reconstruction.download_buffer_size.as_u64()
189        );
190
191        assert_eq!(common.active_downloads.load(Ordering::Relaxed), 0);
192    }
193
194    #[test]
195    fn test_cache_get_or_create_returns_cached_value() {
196        let common = XetCommon::new(&XetConfig::new());
197        let call_count = AtomicUsize::new(0);
198
199        let v1: Arc<Mutex<Vec<i32>>> = common.cache_get_or_create("my_cache", || {
200            call_count.fetch_add(1, Ordering::SeqCst);
201            Arc::new(Mutex::new(vec![1, 2, 3]))
202        });
203
204        let v2: Arc<Mutex<Vec<i32>>> = common.cache_get_or_create("my_cache", || {
205            call_count.fetch_add(1, Ordering::SeqCst);
206            Arc::new(Mutex::new(vec![4, 5, 6]))
207        });
208
209        assert_eq!(call_count.load(Ordering::SeqCst), 1);
210        assert!(Arc::ptr_eq(&v1, &v2));
211    }
212
213    #[test]
214    fn test_cache_different_keys_are_independent() {
215        let common = XetCommon::new(&XetConfig::new());
216
217        let v1: Arc<String> = common.cache_get_or_create("key_a", || Arc::new("alpha".to_string()));
218        let v2: Arc<String> = common.cache_get_or_create("key_b", || Arc::new("beta".to_string()));
219
220        assert_eq!(v1.as_str(), "alpha");
221        assert_eq!(v2.as_str(), "beta");
222    }
223
224    #[test]
225    fn test_cache_type_mismatch_creates_new_entry() {
226        let common = XetCommon::new(&XetConfig::new());
227
228        let _v1: Arc<String> = common.cache_get_or_create("shared_key", || Arc::new("original".to_string()));
229
230        // Same key but different type -- downcast fails, so create is called and the
231        // entry is replaced with the new type.
232        let v2: Arc<i32> = common.cache_get_or_create("shared_key", || Arc::new(42));
233        assert_eq!(*v2, 42);
234    }
235}