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(
200 crate::i18n::validation::refuse_release_slot_without_yes(
201 &slot_id.to_string(),
202 &path.display().to_string(),
203 ),
204 ));
205 }
206 std::fs::remove_file(&path).map_err(AppError::Io)?;
207 let out = serde_json::json!({
208 "action": "slot_released",
209 "slot_id": slot_id,
210 "path": path.to_string_lossy(),
211 });
212 let _ = emit_json_compact(&out);
213 Ok(())
214}
215
216fn run_cleanup(stale_after: u64, yes: bool, dry_run: bool) -> Result<(), AppError> {
217 let start = std::time::Instant::now();
218 let max = crate::llm_slots::default_max_concurrency();
219 let mut removed: Vec<u32> = Vec::new();
220 for slot_id in 0..max {
221 let path = slot_path(slot_id);
222 if !path.is_file() {
223 continue;
224 }
225 let age = path
226 .metadata()
227 .and_then(|m| m.modified())
228 .ok()
229 .and_then(|t| t.elapsed().ok())
230 .map(|d| d.as_secs())
231 .unwrap_or(0);
232 if age >= stale_after {
233 if !dry_run {
234 if let Err(e) = std::fs::remove_file(&path) {
235 tracing::warn!(target: "slots", slot_id, error = %e, "stale slot removal failed");
236 continue;
237 }
238 }
239 removed.push(slot_id);
240 }
241 }
242 let out = serde_json::json!({
243 "action": if dry_run { "slots_cleanup_dry_run" } else { "slots_cleanup" },
244 "stale_after_secs": stale_after,
245 "removed": removed,
246 "removed_count": removed.len(),
247 "elapsed_ms": start.elapsed().as_millis() as u64,
248 "yes": yes,
249 });
250 let _ = emit_json_compact(&out);
251 Ok(())
252}
253
254#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::llm_slots::acquire_llm_slot;
261 use clap::Parser;
262
263 #[test]
264 fn acquire_then_drop_releases_slot() {
265 let _ = std::fs::remove_dir_all(crate::llm_slots::slots_dir());
266 let guard = acquire_llm_slot(2, 5).expect("acquire");
267 let path = slot_path(guard.slot_id());
268 assert!(path.is_file(), "slot file must exist after acquire");
269 drop(guard);
270 assert!(
271 !path.is_file(),
272 "slot file must be removed after Drop (RAII guarantee)"
273 );
274 }
275
276 #[test]
277 fn slots_status_accepts_db_as_noop() {
278 let cli = crate::cli::Cli::try_parse_from([
279 "sqlite-graphrag",
280 "slots",
281 "status",
282 "--db",
283 "/tmp/gap-sg-139-sentinel.sqlite",
284 ])
285 .expect("slots status must accept --db as a no-op (GAP-SG-139)");
286
287 match cli.command {
288 Some(crate::cli::Commands::Slots(args)) => match args.cmd {
289 SlotsCmd::Status(s) => {
290 assert_eq!(
291 s.db_noop.db.as_deref(),
292 Some("/tmp/gap-sg-139-sentinel.sqlite")
293 );
294 }
295 other => panic!("expected Status, got {other:?}"),
296 },
297 other => panic!("expected Slots, got {other:?}"),
298 }
299 }
300}