sqlite_graphrag/commands/
slots.rs1use clap::{Args, Subcommand};
10use serde::Serialize;
11
12use crate::cli_db_noop::{DbNoopArgs, DB_NOOP_HELP};
13use crate::errors::AppError;
14use crate::llm_slots::{slot_path, slots_dir};
15use crate::output::emit_json_compact;
16use crate::output::OutputFormat;
17
18#[derive(Debug, Args)]
21pub struct SlotsArgs {
22 #[command(subcommand)]
24 pub cmd: SlotsCmd,
25}
26
27#[derive(Debug, Subcommand)]
29pub enum SlotsCmd {
30 Status(SlotsStatusArgs),
32 Release {
34 #[arg(long)]
36 slot_id: u32,
37 #[arg(long)]
39 yes: bool,
40 #[arg(long, hide = true)]
42 json: bool,
43 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
45 db: Option<String>,
46 },
47 Cleanup {
49 #[arg(long, default_value_t = 3600)]
51 stale_after: u64,
52 #[arg(long)]
54 yes: bool,
55 #[arg(long)]
57 dry_run: bool,
58 #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
60 db: Option<String>,
61 },
62}
63
64#[derive(Debug, clap::Args)]
66pub struct SlotsStatusArgs {
67 #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
69 pub format: OutputFormat,
70 #[arg(long, hide = true)]
72 pub json: bool,
73 #[command(flatten)]
75 pub db_noop: DbNoopArgs,
76}
77
78#[derive(Serialize)]
79struct SlotEntry {
80 slot_id: u32,
81 path: String,
82 age_secs: u64,
83 pid_hint: Option<u32>,
84}
85
86#[derive(Serialize)]
87struct SlotsStatusOutput {
88 action: &'static str,
89 max_concurrency: u32,
90 active: usize,
91 free: usize,
92 slots: Vec<SlotEntry>,
93 elapsed_ms: u64,
94}
95
96pub fn run(args: SlotsArgs) -> Result<(), AppError> {
98 run_cmd(args.cmd)
99}
100
101fn run_cmd(cmd: SlotsCmd) -> Result<(), AppError> {
102 match cmd {
103 SlotsCmd::Status(args) => run_status(args),
104 SlotsCmd::Release {
105 slot_id,
106 yes,
107 json: _,
108 db: _,
109 } => run_release(slot_id, yes),
110 SlotsCmd::Cleanup {
111 stale_after,
112 yes,
113 dry_run,
114 db: _,
115 } => run_cleanup(stale_after, yes, dry_run),
116 }
117}
118
119fn run_status(args: SlotsStatusArgs) -> Result<(), AppError> {
120 args.db_noop.ignore();
121 let start = std::time::Instant::now();
122 let max = crate::llm_slots::default_max_concurrency();
123 let dir = slots_dir();
124 let mut entries: Vec<SlotEntry> = Vec::new();
125
126 if dir.is_dir() {
127 for slot_id in 0..max {
128 let path = slot_path(slot_id);
129 if path.is_file() {
130 let age_secs = path
131 .metadata()
132 .and_then(|m| m.modified())
133 .ok()
134 .and_then(|t| t.elapsed().ok())
135 .map(|d| d.as_secs())
136 .unwrap_or(0);
137 let pid_hint = std::fs::read_to_string(&path)
138 .ok()
139 .and_then(|s| s.trim().parse::<u32>().ok());
140 entries.push(SlotEntry {
141 slot_id,
142 path: path.to_string_lossy().into_owned(),
143 age_secs,
144 pid_hint,
145 });
146 }
147 }
148 }
149
150 let output = SlotsStatusOutput {
151 action: "slots_status",
152 max_concurrency: max,
153 active: entries.len(),
154 free: (max as usize).saturating_sub(entries.len()),
155 slots: entries,
156 elapsed_ms: start.elapsed().as_millis() as u64,
157 };
158
159 if matches!(args.format, OutputFormat::Json) {
160 let json = serde_json::to_string_pretty(&output).map_err(AppError::Json)?;
161 println!("{json}");
163 } else {
164 tracing::info!(target: "slots", max_concurrency = output.max_concurrency, "slot status");
169 tracing::info!(
170 target: "slots",
171 active = output.active,
172 free = output.free,
173 "slot occupancy"
174 );
175 for s in &output.slots {
176 let pid = s.pid_hint.map(|p| p.to_string()).unwrap_or_default();
177 tracing::info!(
178 target: "slots",
179 slot_id = s.slot_id,
180 age_secs = s.age_secs,
181 pid = %pid,
182 path = %s.path,
183 "slot entry"
184 );
185 }
186 }
187 Ok(())
188}
189
190fn run_release(slot_id: u32, yes: bool) -> Result<(), AppError> {
191 let path = slot_path(slot_id);
192 if !path.is_file() {
193 return Err(AppError::NotFound(format!(
194 "slot {slot_id} is not held (no file at {})",
195 path.display()
196 )));
197 }
198 if !yes {
199 return Err(AppError::Validation(crate::i18n::validation::refuse_release_slot_without_yes(
200 &slot_id.to_string(),
201 &path.display().to_string(),
202 )));
203 }
204 std::fs::remove_file(&path).map_err(AppError::Io)?;
205 let out = serde_json::json!({
206 "action": "slot_released",
207 "slot_id": slot_id,
208 "path": path.to_string_lossy(),
209 });
210 let _ = emit_json_compact(&out);
211 Ok(())
212}
213
214fn run_cleanup(stale_after: u64, yes: bool, dry_run: bool) -> Result<(), AppError> {
215 let start = std::time::Instant::now();
216 let max = crate::llm_slots::default_max_concurrency();
217 let mut removed: Vec<u32> = Vec::new();
218 for slot_id in 0..max {
219 let path = slot_path(slot_id);
220 if !path.is_file() {
221 continue;
222 }
223 let age = path
224 .metadata()
225 .and_then(|m| m.modified())
226 .ok()
227 .and_then(|t| t.elapsed().ok())
228 .map(|d| d.as_secs())
229 .unwrap_or(0);
230 if age >= stale_after {
231 if !dry_run {
232 if let Err(e) = std::fs::remove_file(&path) {
233 tracing::warn!(target: "slots", slot_id, error = %e, "stale slot removal failed");
234 continue;
235 }
236 }
237 removed.push(slot_id);
238 }
239 }
240 let out = serde_json::json!({
241 "action": if dry_run { "slots_cleanup_dry_run" } else { "slots_cleanup" },
242 "stale_after_secs": stale_after,
243 "removed": removed,
244 "removed_count": removed.len(),
245 "elapsed_ms": start.elapsed().as_millis() as u64,
246 "yes": yes,
247 });
248 let _ = emit_json_compact(&out);
249 Ok(())
250}
251
252#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::llm_slots::acquire_llm_slot;
259 use clap::Parser;
260
261 #[test]
262 fn acquire_then_drop_releases_slot() {
263 let _ = std::fs::remove_dir_all(crate::llm_slots::slots_dir());
264 let guard = acquire_llm_slot(2, 5).expect("acquire");
265 let path = slot_path(guard.slot_id());
266 assert!(path.is_file(), "slot file must exist after acquire");
267 drop(guard);
268 assert!(
269 !path.is_file(),
270 "slot file must be removed after Drop (RAII guarantee)"
271 );
272 }
273
274 #[test]
275 fn slots_status_accepts_db_as_noop() {
276 let cli = crate::cli::Cli::try_parse_from([
277 "sqlite-graphrag",
278 "slots",
279 "status",
280 "--db",
281 "/tmp/gap-sg-139-sentinel.sqlite",
282 ])
283 .expect("slots status must accept --db as a no-op (GAP-SG-139)");
284
285 match cli.command {
286 Some(crate::cli::Commands::Slots(args)) => match args.cmd {
287 SlotsCmd::Status(s) => {
288 assert_eq!(
289 s.db_noop.db.as_deref(),
290 Some("/tmp/gap-sg-139-sentinel.sqlite")
291 );
292 }
293 other => panic!("expected Status, got {other:?}"),
294 },
295 other => panic!("expected Slots, got {other:?}"),
296 }
297 }
298}