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 "max_concurrent deve ser >= 1 para acquire_llm_slot".to_string(),
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(format!(
123 "failed to acquire LLM slot within {wait_secs}s (max={max_concurrent} concurrent)"
124 )));
125 }
126 std::thread::sleep(Duration::from_millis(100));
127 }
128}
129
130#[derive(Debug, Clone, serde::Serialize)]
132pub struct SlotStatus {
133 pub max: u32,
135 pub active: u32,
137 pub pids: Vec<u32>,
139}
140
141pub fn read_status(max_concurrent: u32) -> SlotStatus {
143 let mut active = 0u32;
144 let mut pids = Vec::new();
145 for slot_id in 0..max_concurrent {
146 let path = slot_path(slot_id);
147 if path.exists() {
148 active += 1;
149 if let Ok(content) = fs::read_to_string(&path) {
150 if let Some(pid_line) = content.lines().find(|l| l.starts_with("pid=")) {
151 if let Ok(pid) = pid_line[4..].parse::<u32>() {
152 pids.push(pid);
153 }
154 }
155 }
156 }
157 }
158 SlotStatus {
159 max: max_concurrent,
160 active,
161 pids,
162 }
163}
164
165pub fn force_release(slot_id: u32) -> Result<(), AppError> {
167 let path = slot_path(slot_id);
168 if path.exists() {
169 fs::remove_file(&path).map_err(|e| {
170 AppError::Io(std::io::Error::new(
171 e.kind(),
172 format!("failed to release slot {slot_id}: {e}"),
173 ))
174 })?;
175 }
176 Ok(())
177}
178
179pub fn find_stale_slots(max_concurrent: u32) -> Vec<u32> {
181 let mut stale = Vec::new();
182 for slot_id in 0..max_concurrent {
183 let path = slot_path(slot_id);
184 if path.exists() {
185 if let Ok(content) = fs::read_to_string(&path) {
186 if let Some(pid_line) = content.lines().find(|l| l.starts_with("pid=")) {
187 if let Ok(pid) = pid_line[4..].parse::<u32>() {
188 if !pid_alive(pid) {
189 stale.push(slot_id);
190 }
191 }
192 }
193 }
194 }
195 }
196 stale
197}
198
199#[cfg(unix)]
201fn pid_alive(pid: u32) -> bool {
202 unsafe { libc::kill(pid as i32, 0) == 0 }
204}
205
206#[cfg(not(unix))]
207fn pid_alive(pid: u32) -> bool {
208 let _ = pid;
211 true
212}
213
214pub fn slots_dir() -> PathBuf {
216 if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") {
219 if !runtime.is_empty() {
220 return PathBuf::from(runtime).join("sqlite-graphrag/llm-slots");
221 }
222 }
223 if let Ok(Some(cache)) = crate::config::get_setting("cache.dir") {
224 if !cache.is_empty() {
225 return PathBuf::from(cache).join("llm-slots");
226 }
227 }
228 match crate::paths::cache_dir() {
230 Ok(cache) => cache.join("llm-slots"),
231 Err(_) => std::env::temp_dir().join("sqlite-graphrag/llm-slots"),
232 }
233}
234
235pub fn slot_path(id: u32) -> PathBuf {
237 slots_dir().join(format!("slot-{id}.lock"))
238}
239
240pub fn default_max_concurrency() -> u32 {
250 let cpus = std::thread::available_parallelism()
251 .map(|n| n.get() as u32)
252 .unwrap_or(4);
253 let assumed_available_mb: u32 = 4096;
259 let per_worker = crate::constants::LLM_WORKER_RSS_MB as u32;
260 let safe = assumed_available_mb / per_worker.max(1);
261 let capped = safe.min(crate::constants::MAX_CONCURRENT_CLI_INSTANCES as u32);
262 cpus.min(capped).max(1)
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::sync::Arc;
269 use std::sync::Barrier;
270 use std::thread;
271
272 static SLOT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
276
277 fn unique_test_dir() -> PathBuf {
278 let mut dir = std::env::temp_dir();
279 dir.push(format!(
280 "llm-slots-test-{}-{}",
281 std::process::id(),
282 std::time::SystemTime::now()
283 .duration_since(std::time::UNIX_EPOCH)
284 .unwrap()
285 .as_nanos()
286 ));
287 dir
288 }
289
290 fn isolate_slots_env() -> (Option<String>, Option<String>) {
291 let orig_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
292 std::env::set_var("XDG_RUNTIME_DIR", unique_test_dir());
294 (orig_xdg, None)
295 }
296
297 fn restore_slots_env(orig_xdg: Option<String>, _orig_cache: Option<String>) {
298 match orig_xdg {
299 Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
300 None => std::env::remove_var("XDG_RUNTIME_DIR"),
301 }
302 }
303
304 #[test]
305 fn slot_enforces_max_concurrency() {
306 let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
307 let (orig_xdg, orig_cache) = isolate_slots_env();
308
309 let _g1 = acquire_llm_slot(2, 5).expect("first slot");
310 let _g2 = acquire_llm_slot(2, 5).expect("second slot");
311 let start = std::time::Instant::now();
312 let result = acquire_llm_slot(2, 1);
313 assert!(result.is_err(), "third slot should fail with max=2");
314 assert!(
315 start.elapsed() >= std::time::Duration::from_secs(1),
316 "should wait full timeout before failing"
317 );
318
319 restore_slots_env(orig_xdg, orig_cache);
320 }
321
322 #[test]
323 fn slot_releases_on_drop() {
324 let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
325 let (orig_xdg, orig_cache) = isolate_slots_env();
326
327 let g1 = acquire_llm_slot(1, 5).expect("first slot");
328 drop(g1);
329 let _g2 = acquire_llm_slot(1, 5).expect("second slot after drop");
330
331 restore_slots_env(orig_xdg, orig_cache);
332 }
333
334 #[test]
335 fn slot_max_concurrent_zero_is_validation_error() {
336 let result = acquire_llm_slot(0, 1);
337 assert!(matches!(result, Err(AppError::Validation(_))));
338 }
339
340 #[test]
341 fn read_status_reflects_active_slots() {
342 let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
343 let (orig_xdg, orig_cache) = isolate_slots_env();
344
345 let _g1 = acquire_llm_slot(4, 5).expect("first slot");
346 let status = read_status(4);
347 assert_eq!(status.max, 4);
348 assert!(status.active >= 1);
349 assert!(!status.pids.is_empty());
350
351 restore_slots_env(orig_xdg, orig_cache);
352 }
353
354 #[test]
355 fn concurrent_acquires_with_2_threads_serialize() {
356 let _serial = SLOT_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
357 let (orig_xdg, orig_cache) = isolate_slots_env();
358
359 let barrier = Arc::new(Barrier::new(3));
360 let mut handles = vec![];
361 for _ in 0..3 {
362 let b = barrier.clone();
363 handles.push(thread::spawn(move || {
364 b.wait();
365 acquire_llm_slot(2, 5)
366 }));
367 }
368 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
369 let successes = results.iter().filter(|r| r.is_ok()).count();
370 assert!(successes >= 1);
372
373 restore_slots_env(orig_xdg, orig_cache);
374 }
375}