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