Skip to main content

sqlite_graphrag/commands/
slots.rs

1//! GAP-004 (v1.0.82): `slots` subcommand — inspect and manage the
2//! cross-process LLM slot semaphore.
3//!
4//! ## Subcommands
5//! - `slots status` — list active slot files and the PID that holds them
6//! - `slots release --slot-id N` — force-release a specific slot
7//! - `slots cleanup --stale-after N` — remove slots older than N seconds
8
9use 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/// Outer wrapper that lets the top-level `Cli` enum carry `Slots` as an `Args`
19/// variant while preserving the inner `Status | Release | Cleanup` subcommand tree.
20#[derive(Debug, Args)]
21pub struct SlotsArgs {
22    /// Cmd.
23    #[command(subcommand)]
24    pub cmd: SlotsCmd,
25}
26
27/// Slots cmd.
28#[derive(Debug, Subcommand)]
29pub enum SlotsCmd {
30    /// List currently-held LLM slots and their PIDs.
31    Status(SlotsStatusArgs),
32    /// Force-release a slot by id (admin only).
33    Release {
34        /// Slot id (0..max-1) to release.
35        #[arg(long)]
36        slot_id: u32,
37        /// Skip the interactive confirmation prompt.
38        #[arg(long)]
39        yes: bool,
40        /// JSON output (always on; accepted for CLI consistency).
41        #[arg(long, hide = true)]
42        json: bool,
43        /// GAP-SG-139: accepted as a no-op (host-wide slots; no graph I/O).
44        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
45        db: Option<String>,
46    },
47    /// Remove slot files older than `stale-after` seconds.
48    Cleanup {
49        /// Age in seconds after which a slot is considered stale.
50        #[arg(long, default_value_t = 3600)]
51        stale_after: u64,
52        /// Skip the interactive confirmation prompt.
53        #[arg(long)]
54        yes: bool,
55        /// Dry-run: list what would be removed without touching the filesystem.
56        #[arg(long)]
57        dry_run: bool,
58        /// GAP-SG-139: accepted as a no-op (host-wide slots; no graph I/O).
59        #[arg(long, value_name = "PATH", help = DB_NOOP_HELP)]
60        db: Option<String>,
61    },
62}
63
64/// Slots status args.
65#[derive(Debug, clap::Args)]
66pub struct SlotsStatusArgs {
67    /// Output format.
68    #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
69    pub format: OutputFormat,
70    /// JSON output (always on; accepted for CLI consistency with other subcommands).
71    #[arg(long, hide = true)]
72    pub json: bool,
73    /// GAP-SG-139: accepted as a no-op for agent uniformity (host-wide slots).
74    #[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
96/// Run.
97pub 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        // GAP-SG-142: the payload goes through `output` like every other
161        // envelope, so it inherits BrokenPipe tolerance and the agent-native
162        // reshaping surface instead of bypassing both via `println!`.
163        crate::output::emit_json(&output)?;
164    } else {
165        // GAP-007 (v1.0.88): text-mode output now flows through the
166        // `tracing` pipeline (target: "slots") instead of `println!` so
167        // operators can filter slot events independently and so the
168        // output is captured by the structured-log sinks in CI.
169        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    // GAP-SG-204: `let _ =` here swallowed every emission failure, including the
214    // agent-native refusals the surface raises from v1.2.6 on. A caller that
215    // asked for an impossible projection would have seen exit 0 and no payload.
216    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    // See `run_release` above for why this is no longer discarded.
255    emit_json_compact(&out)?;
256    Ok(())
257}
258
259/// Sanity: `acquire_llm_slot` then immediately drop the guard must
260/// remove the slot file. This is the test that GAP-004 depends on
261/// for the cross-process guarantee.
262#[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}