sqlite_graphrag/
llm_slots.rs1use fs4::fs_std::FileExt;
26use std::fs::{self, File, OpenOptions};
27use std::path::PathBuf;
28use std::time::{Duration, Instant};
29
30use crate::errors::AppError;
31
32pub struct LlmSlotGuard {
35 #[allow(dead_code)]
36 slot_file: File,
37 slot_id: u32,
38 acquired_at: Instant,
39}
40
41impl LlmSlotGuard {
42 pub fn slot_id(&self) -> u32 {
45 self.slot_id
46 }
47}
48
49impl Drop for LlmSlotGuard {
50 fn drop(&mut self) {
51 let path = slot_path(self.slot_id);
54 if let Err(e) = fs::remove_file(&path) {
55 tracing::debug!(slot_id = self.slot_id, error = %e, "slot file removal failed (already gone?)");
56 }
57 tracing::debug!(
58 slot_id = self.slot_id,
59 held_ms = self.acquired_at.elapsed().as_millis() as u64,
60 "llm slot released"
61 );
62 }
63}
64
65pub fn acquire_llm_slot(max_concurrent: u32, wait_secs: u64) -> Result<LlmSlotGuard, AppError> {
74 if max_concurrent == 0 {
75 return Err(AppError::Validation(
76 crate::i18n::validation::llm_slot_ceiling_must_be_positive(),
77 ));
78 }
79 let dir = slots_dir();
80 fs::create_dir_all(&dir).map_err(|e| {
81 AppError::Io(std::io::Error::new(
82 e.kind(),
83 format!("failed to create slots dir {}: {e}", dir.display()),
84 ))
85 })?;
86
87 let stale = find_stale_slots(max_concurrent);
88 for slot_id in &stale {
89 let _ = force_release(*slot_id);
90 tracing::info!(slot_id, "released stale LLM slot (PID dead)");
91 }
92
93 let start = Instant::now();
94 let timeout = Duration::from_secs(wait_secs);
95
96 loop {
97 for slot_id in 0..max_concurrent {
98 let path = slot_path(slot_id);
99 match OpenOptions::new().write(true).create_new(true).open(&path) {
100 Ok(mut file) => {
101 if file.try_lock_exclusive().is_ok() {
102 let pid = std::process::id();
103 use std::io::Write;
105 let _ = writeln!(file, "pid={pid}");
106 tracing::debug!(slot_id, pid, "llm slot acquired");
107 return Ok(LlmSlotGuard {
108 slot_file: file,
109 slot_id,
110 acquired_at: Instant::now(),
111 });
112 }
113 }
115 Err(_) => {
116 }
118 }
119 }
120 if start.elapsed() >= timeout {
122 return Err(AppError::LockBusy(
123 crate::i18n::errors_ops::llm_slot_acquire_timeout(wait_secs, max_concurrent),
124 ));
125 }
126 std::thread::sleep(Duration::from_millis(
127 crate::constants::LLM_SLOT_POLL_INTERVAL_MS,
128 ));
129 }
130}
131
132#[derive(Debug, Clone, serde::Serialize)]
134pub struct SlotStatus {
135 pub max: u32,
137 pub active: u32,
139 pub pids: Vec<u32>,
141}
142
143pub fn read_status(max_concurrent: u32) -> SlotStatus {
145 let mut active = 0u32;
146 let mut pids = Vec::new();
147 for slot_id in 0..max_concurrent {
148 let path = slot_path(slot_id);
149 if path.exists() {
150 active += 1;
151 if let Ok(content) = fs::read_to_string(&path) {
152 if let Some(pid_line) = content.lines().find(|l| l.starts_with("pid=")) {
153 if let Ok(pid) = pid_line[4..].parse::<u32>() {
154 pids.push(pid);
155 }
156 }
157 }
158 }
159 }
160 SlotStatus {
161 max: max_concurrent,
162 active,
163 pids,
164 }
165}
166
167pub fn force_release(slot_id: u32) -> Result<(), AppError> {
169 let path = slot_path(slot_id);
170 if path.exists() {
171 fs::remove_file(&path).map_err(|e| {
172 AppError::Io(std::io::Error::new(
173 e.kind(),
174 format!("failed to release slot {slot_id}: {e}"),
175 ))
176 })?;
177 }
178 Ok(())
179}
180
181pub fn find_stale_slots(max_concurrent: u32) -> Vec<u32> {
183 let mut stale = Vec::new();
184 for slot_id in 0..max_concurrent {
185 let path = slot_path(slot_id);
186 if path.exists() {
187 if let Ok(content) = fs::read_to_string(&path) {
188 if let Some(pid_line) = content.lines().find(|l| l.starts_with("pid=")) {
189 if let Ok(pid) = pid_line[4..].parse::<u32>() {
190 if !pid_alive(pid) {
191 stale.push(slot_id);
192 }
193 }
194 }
195 }
196 }
197 }
198 stale
199}
200
201#[cfg(unix)]
203fn pid_alive(pid: u32) -> bool {
204 unsafe { libc::kill(pid as i32, 0) == 0 }
206}
207
208#[cfg(not(unix))]
209fn pid_alive(pid: u32) -> bool {
210 let _ = pid;
213 true
214}
215
216pub fn slots_dir() -> PathBuf {
218 if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
221 if !runtime.is_empty() {
222 return PathBuf::from(runtime).join("sqlite-graphrag/llm-slots");
223 }
224 }
225 if let Ok(Some(cache)) = crate::config::get_setting("cache.dir") {
226 if !cache.is_empty() {
227 return PathBuf::from(cache).join("llm-slots");
228 }
229 }
230 match crate::paths::cache_dir() {
232 Ok(cache) => cache.join("llm-slots"),
233 Err(_) => std::env::temp_dir().join("sqlite-graphrag/llm-slots"),
234 }
235}
236
237pub fn slot_path(id: u32) -> PathBuf {
239 slots_dir().join(format!("slot-{id}.lock"))
240}
241
242pub fn default_max_concurrency() -> u32 {
252 let cpus = std::thread::available_parallelism()
253 .map(|n| n.get() as u32)
254 .unwrap_or(4);
255 let assumed_available_mb = crate::constants::LLM_SLOT_ASSUMED_AVAILABLE_MB;
260 let per_worker = u32::try_from(crate::constants::llm_worker_rss_mb()).unwrap_or(u32::MAX);
263 let safe = assumed_available_mb / per_worker.max(1);
264 let capped = safe.min(crate::constants::MAX_CONCURRENT_CLI_INSTANCES as u32);
265 cpus.min(capped).max(1)
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use std::sync::Arc;
272 use std::sync::Barrier;
273 use std::thread;
274
275 static SLOT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
279
280 fn unique_test_dir() -> PathBuf {
281 let mut dir = std::env::temp_dir();
282 dir.push(format!(
283 "llm-slots-test-{}-{}",
284 std::process::id(),
285 std::time::SystemTime::now()
286 .duration_since(std::time::UNIX_EPOCH)
287 .unwrap()
288 .as_nanos()
289 ));
290 dir
291 }
292
293 fn isolate_slots_env() -> (Option<String>, Option<String>) {
294 let orig_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
295 std::env::set_var("XDG_RUNTIME_DIR", unique_test_dir());
297 (orig_xdg, None)
298 }
299
300 fn restore_slots_env(orig_xdg: Option<String>, _orig_cache: Option<String>) {
301 match orig_xdg {
302 Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
303 None => std::env::remove_var("XDG_RUNTIME_DIR"),
304 }
305 }
306
307 #[test]
308 fn slot_enforces_max_concurrency() {
309 let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
310 let (orig_xdg, orig_cache) = isolate_slots_env();
311
312 let _g1 = acquire_llm_slot(2, 5).expect("first slot");
313 let _g2 = acquire_llm_slot(2, 5).expect("second slot");
314 let start = std::time::Instant::now();
315 let result = acquire_llm_slot(2, 1);
316 assert!(result.is_err(), "third slot should fail with max=2");
317 assert!(
318 start.elapsed() >= std::time::Duration::from_secs(1),
319 "should wait full timeout before failing"
320 );
321
322 restore_slots_env(orig_xdg, orig_cache);
323 }
324
325 #[test]
326 fn slot_releases_on_drop() {
327 let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
328 let (orig_xdg, orig_cache) = isolate_slots_env();
329
330 let g1 = acquire_llm_slot(1, 5).expect("first slot");
331 drop(g1);
332 let _g2 = acquire_llm_slot(1, 5).expect("second slot after drop");
333
334 restore_slots_env(orig_xdg, orig_cache);
335 }
336
337 #[test]
338 fn slot_max_concurrent_zero_is_validation_error() {
339 let result = acquire_llm_slot(0, 1);
340 assert!(matches!(result, Err(AppError::Validation(_))));
341 }
342
343 #[test]
344 fn read_status_reflects_active_slots() {
345 let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
346 let (orig_xdg, orig_cache) = isolate_slots_env();
347
348 let _g1 = acquire_llm_slot(4, 5).expect("first slot");
349 let status = read_status(4);
350 assert_eq!(status.max, 4);
351 assert!(status.active >= 1);
352 assert!(!status.pids.is_empty());
353
354 restore_slots_env(orig_xdg, orig_cache);
355 }
356
357 #[test]
358 fn concurrent_acquires_with_2_threads_serialize() {
359 let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
360 let (orig_xdg, orig_cache) = isolate_slots_env();
361
362 let barrier = Arc::new(Barrier::new(3));
363 let mut handles = vec![];
364 for _ in 0..3 {
365 let b = barrier.clone();
366 handles.push(thread::spawn(move || {
367 b.wait();
368 acquire_llm_slot(2, 5)
369 }));
370 }
371 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
372 let successes = results.iter().filter(|r| r.is_ok()).count();
373 assert!(successes >= 1);
375
376 restore_slots_env(orig_xdg, orig_cache);
377 }
378}