Skip to main content

coop/
jobs.rs

1use anyhow::{Context, Result, bail};
2use base64::Engine;
3
4use crate::config::{Config, Host};
5use crate::probe::State;
6use crate::transport::Transport;
7use crate::wrapper::{JOBS_ROOT, JobId, state_dir};
8
9/// How far back `ls` reaches for finished jobs, absent `--all`.
10///
11/// Long enough that a job you fired and forgot is still listed when you come
12/// back to it, short enough that the default view does not become an archive.
13/// Anything prune will eventually delete was therefore visible for its first
14/// day.
15const DEFAULT_LS_WINDOW_SECS: u64 = 24 * 60 * 60;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Row {
19    pub id: String,
20    pub host: String,
21    pub state: State,
22    pub age_secs: u64,
23    pub runtime_secs: Option<u64>,
24    pub cmd: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Unreachable {
29    pub host: String,
30    pub why: String,
31    /// The command that would fix it, when there is one.
32    ///
33    /// `ls` treats a down master as information rather than an error, so it
34    /// never built the typed `NoMaster` that carries this -- leaving the user a
35    /// diagnosis with no remedy, unlike every other verb.
36    pub remedy: Option<String>,
37}
38
39pub fn list(
40    cfg: &Config,
41    transport: &dyn Transport,
42    host_filter: Option<&str>,
43    all: bool,
44) -> Result<(Vec<Row>, Vec<Unreachable>)> {
45    let (rows, unreachable, _) = list_with_hidden(cfg, transport, host_filter, all)?;
46    Ok((rows, unreachable))
47}
48
49pub fn list_with_hidden(
50    cfg: &Config,
51    transport: &dyn Transport,
52    host_filter: Option<&str>,
53    all: bool,
54) -> Result<(Vec<Row>, Vec<Unreachable>, usize)> {
55    let hosts: Vec<&Host> = match host_filter {
56        Some(name) => vec![cfg.host(Some(name))?],
57        None => cfg.hosts().iter().collect(),
58    };
59    let mut rows = Vec::new();
60    let mut unreachable = Vec::new();
61    let mut hidden = 0;
62
63    // Each host takes the same ticket lock, so parallel calls would only queue
64    // at the lock while making error reporting and ordering less predictable.
65    for host in hosts {
66        if !transport.master_alive(host) {
67            unreachable.push(Unreachable {
68                host: host.name.clone(),
69                why: "no control master".into(),
70                remedy: Some(crate::errors::master_command(host)),
71            });
72            continue;
73        }
74        let output = transport.run(host, &list_script(host))?;
75        if output.code != 0 {
76            bail!(
77                "listing jobs on {} failed: {}",
78                host.name,
79                output.stderr.trim()
80            );
81        }
82        hidden += parse_rows(host, &output.text(), all, &mut rows)?;
83    }
84    Ok((rows, unreachable, hidden))
85}
86
87/// One round trip, and a bounded number of processes regardless of job count.
88///
89/// The previous version was a shell loop forking four processes PER JOB -- a
90/// `cat` for `rc`, a `tmux has-session`, a `stat`, and a `base64` for `cmd`.
91/// Measured at 300 jobs: **14.1s**, all of it inside the ticket lock, so
92/// nothing else coop-related could run. That breaks coop's own rule against
93/// holding a capped channel for more than about a second, and since `keep_days`
94/// defaults to 14, a few hundred jobs is ordinary rather than pathological.
95///
96/// Now three processes total -- one `tmux list-sessions`, one `find -exec stat`,
97/// one `awk` -- and **0.13s** for the same 300 jobs, a 108x improvement. What
98/// each step bought, measured separately: dropping per-job `has-session` took
99/// 14.1s to 5.8s (each was a separate tmux client connection), batching `stat`
100/// took it to 2.4s, and moving the file reads into awk took it to 0.13s.
101///
102/// Two portability notes, both load-bearing rather than defensive:
103///
104/// `stat`'s flags are mutually exclusive between BSD and GNU -- `-c` is an
105/// illegal option on macOS and `-f` means "file system" on Linux -- so the
106/// `||` fallback is required, not belt-and-braces.
107///
108/// `cmd` is base64-encoded inside awk. A command may contain a tab or newline,
109/// either of which would corrupt the row format. Keeping the encoder in the
110/// existing awk process avoids restoring the per-job `base64` forks that made
111/// listing take 14.1s. The same batched stat includes `cmd` and `rc`: spawning
112/// one stat per artifact would undo that improvement.
113fn list_script(host: &Host) -> String {
114    format!(
115        "root={JOBS_ROOT}; [ -d \"$root\" ] || exit 0; \
116         live=$(tmux -L {} list-sessions -F '#{{session_name}}' 2>/dev/null | sed 's/^coop-//'); \
117         {{ find \"$root\" -mindepth 1 -maxdepth 2 \\( \\( -type d ! -path \"$root/*/*\" \\) -o \\( -type f \\( -name cmd -o -name rc \\) \\) \\) -exec stat -c '%Y %n' {{}} + 2>/dev/null \
118            || find \"$root\" -mindepth 1 -maxdepth 2 \\( \\( -type d ! -path \"$root/*/*\" \\) -o \\( -type f \\( -name cmd -o -name rc \\) \\) \\) -exec stat -f '%m %N' {{}} + ; }} \
119         | awk -v now=\"$(date +%s)\" -v live=\"$live\" '\
120             BEGIN {{ \
121               n = split(live, L, \"\\n\"); \
122               for (i = 1; i <= n; i++) if (L[i] != \"\") alive[L[i]] = 1; \
123               alphabet = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"; \
124               for (i = 0; i < 256; i++) ord[sprintf(\"%c\", i)] = i; \
125             }} \
126             function b64(s,   out, i, n, a, b, c) {{ \
127               for (i = 1; i <= length(s); i += 3) {{ \
128                 n = length(s) - i + 1; \
129                 a = ord[substr(s, i, 1)]; \
130                 b = n > 1 ? ord[substr(s, i + 1, 1)] : 0; \
131                 c = n > 2 ? ord[substr(s, i + 2, 1)] : 0; \
132                 out = out substr(alphabet, int(a / 4) + 1, 1); \
133                 out = out substr(alphabet, (a % 4) * 16 + int(b / 16) + 1, 1); \
134                 out = out (n > 1 ? substr(alphabet, (b % 16) * 4 + int(c / 64) + 1, 1) : \"=\"); \
135                 out = out (n > 2 ? substr(alphabet, c % 64 + 1, 1) : \"=\"); \
136               }} \
137               return out; \
138             }} \
139             {{ \
140               mtime = $1; path = substr($0, length($1) + 2); \
141               dir = path; kind = \"dir\"; \
142               if (sub(/\\/cmd$/, \"\", dir)) kind = \"cmd\"; \
143               else if (sub(/\\/rc$/, \"\", dir)) kind = \"rc\"; \
144               id = dir; sub(/.*\\//, \"\", id); \
145               dirs[id] = dir; \
146               if (kind == \"dir\") dir_mtime[id] = mtime; \
147               else if (kind == \"cmd\") cmd_mtime[id] = mtime; \
148               else rc_mtime[id] = mtime; \
149             }} \
150             END {{ \
151               for (id in dirs) {{ \
152                 dir = dirs[id]; \
153                 rc = \"\"; if ((getline l < (dir \"/rc\")) > 0) rc = l; \
154                 close(dir \"/rc\"); \
155                 cmd = \"\"; \
156                 while ((getline l < (dir \"/cmd\")) > 0) cmd = (cmd == \"\") ? l : cmd \"\\n\" l; \
157                 close(dir \"/cmd\"); \
158                 runtime = \"\"; \
159                 if (rc != \"\" && (id in cmd_mtime) && (id in rc_mtime)) \
160                   runtime = rc_mtime[id] - cmd_mtime[id]; \
161                 else if (rc == \"\" && (id in alive) && (id in cmd_mtime)) \
162                   runtime = now - cmd_mtime[id]; \
163                 if (runtime != \"\" && runtime < 0) runtime = 0; \
164                 printf \"%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n\", \
165                   id, now - dir_mtime[id], runtime, rc, (id in alive) ? 1 : 0, b64(cmd); \
166               }} \
167             }}'",
168        host.tmux_socket
169    )
170}
171
172fn parse_rows(host: &Host, reply: &str, all: bool, rows: &mut Vec<Row>) -> Result<usize> {
173    let mut hidden = 0;
174    for line in reply.lines() {
175        let mut fields = line.splitn(6, '\t');
176        let id = fields.next().context("invalid ls reply: missing id")?;
177        let age_secs = fields
178            .next()
179            .context("invalid ls reply: missing age")?
180            .parse()
181            .context("invalid age in ls reply")?;
182        let runtime_text = fields.next().context("invalid ls reply: missing runtime")?;
183        let runtime_secs = if runtime_text.is_empty() {
184            None
185        } else {
186            Some(
187                runtime_text
188                    .parse()
189                    .context("invalid runtime in ls reply")?,
190            )
191        };
192        let rc_text = fields.next().context("invalid ls reply: missing rc")?;
193        let alive = fields.next().context("invalid ls reply: missing alive")? == "1";
194        let cmd = String::from_utf8_lossy(&decode_command(
195            fields.next().context("invalid ls reply: missing cmd")?,
196        )?)
197        .into_owned();
198        let rc = if rc_text.is_empty() {
199            None
200        } else {
201            Some(rc_text.parse().context("invalid rc in ls reply")?)
202        };
203        let state = match rc {
204            Some(code) => State::Done(code),
205            None if alive => State::Running,
206            None => State::Orphan,
207        };
208        // An orphan has no completion timestamp, so elapsed time since dispatch
209        // is not its runtime. Keep the value absent rather than repeat AGE's
210        // old mistake of giving one number two meanings.
211        let runtime_secs = (!matches!(state, State::Orphan))
212            .then_some(runtime_secs)
213            .flatten();
214        // Time-based, not state-based. Filtering on `done` treated finished
215        // work as noise the caller had already seen -- true for a job watched
216        // with `--wait`, false for every job dispatched and walked away from,
217        // which is the mode this tool exists for. A short command is ALREADY
218        // done when the user first looks, so a state filter made `ls` empty
219        // exactly when it is the documented recovery path for a lost id.
220        //
221        // `running` and `orphan` are never hidden at any age: one is live, the
222        // other is evidence.
223        let recent = age_secs < DEFAULT_LS_WINDOW_SECS;
224        if all || recent || !matches!(state, State::Done(_)) {
225            rows.push(Row {
226                id: id.into(),
227                host: host.name.clone(),
228                state,
229                age_secs,
230                runtime_secs,
231                cmd,
232            });
233        } else {
234            hidden += 1;
235        }
236    }
237    Ok(hidden)
238}
239
240pub fn kill(transport: &dyn Transport, host: &Host, id: &JobId) -> Result<i32> {
241    crate::errors::require_master(transport, host)?;
242    let dir = state_dir(id);
243    // Destroy is best-effort: a finished job, a second kill, or a watchdog that
244    // already tore the session down must still return rc. `&& cat` made
245    // kill-session's failure hide that no-op. Cancel watch-{id} too, or a
246    // capped job's sleeper lives until max_secs and can overwrite rc with 124.
247    let script = format!(
248        "d={dir}; [ -f $d/rc ] || echo 137 > $d/rc; \
249         tmux -L {socket} kill-session -t coop-{id} 2>/dev/null; \
250         tmux -L {socket} kill-session -t watch-{id} 2>/dev/null; \
251         cat $d/rc",
252        socket = host.tmux_socket
253    );
254    let output = transport.run(host, &script)?;
255    if output.code != 0 {
256        bail!("kill failed: {}", output.stderr.trim());
257    }
258    output
259        .text()
260        .trim()
261        .parse()
262        .context("kill returned an invalid exit code")
263}
264
265/// What `rm` was asked to remove.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub enum Target {
268    One(JobId),
269    /// Every finished job, ignoring `keep_days`.
270    ///
271    /// Deliberately NOT "everything": `--all` never stops work. A running job
272    /// is spared, and so is an orphan -- it has no `rc`, and it is the one
273    /// state that cannot be reconstructed, so it is evidence rather than mud.
274    /// That is what makes `--all` safe enough to need no confirmation. `rm
275    /// <id>` ends the named job; only this bulk path is non-destructive.
276    AllDone,
277}
278
279/// Remove job state. Returns the ids removed, so the caller can report them.
280///
281/// One round trip either way: enumeration and removal share a single remote
282/// script, because a list-then-delete pair would take the lock twice and could
283/// act on a job whose state changed in between.
284pub fn remove(transport: &dyn Transport, host: &Host, target: &Target) -> Result<Vec<String>> {
285    crate::errors::require_master(transport, host)?;
286
287    let script = match target {
288        // A single id still kills first: the caller named this job, so ending
289        // it is the intent. Only the bulk path is non-destructive.
290        Target::One(id) => {
291            let dir = state_dir(id);
292            format!(
293                "tmux -L {} kill-session -t coop-{id} 2>/dev/null; \
294                 if [ -d {dir} ]; then rm -rf {dir} && echo {id}; fi; exit 0",
295                host.tmux_socket
296            )
297        }
298        // Presence of `rc` IS the definition of finished, the same test prune
299        // uses -- so this is "prune now, ignoring the horizon".
300        Target::AllDone => format!(
301            "root={JOBS_ROOT}; [ -d \"$root\" ] || exit 0; \
302             for d in \"$root\"/*; do \
303               [ -d \"$d\" ] && [ -f \"$d/rc\" ] || continue; \
304               rm -rf \"$d\" && echo \"${{d##*/}}\"; \
305             done; exit 0"
306        ),
307    };
308
309    let output = transport.run(host, &script)?;
310    if output.code != 0 {
311        bail!("rm failed on {}: {}", host.name, output.stderr.trim());
312    }
313    Ok(output
314        .text()
315        .lines()
316        .map(str::trim)
317        .filter(|line| !line.is_empty())
318        .map(str::to_string)
319        .collect())
320}
321
322/// How much longer an `orphan` is kept than a finished job.
323///
324/// An orphan is evidence -- the host rebooted, or something killed the session
325/// -- and since `kill` writes rc 137, it means strictly "not coop's doing". So
326/// it outlives ordinary output by a wide margin. But not forever: a disk-full
327/// incident produces orphans holding the largest logs on the host, and those
328/// were exactly the directories an unconditional exemption refused to touch,
329/// leaving permanent residue only a human could clear.
330const ORPHAN_KEEP_MULTIPLIER: u32 = 4;
331
332pub fn prune(host: &Host) -> String {
333    let orphan_days = host.keep_days.saturating_mul(ORPHAN_KEEP_MULTIPLIER);
334    // Two passes, because the two states have different horizons and `find`
335    // cannot express "has rc OR is much older" in one predicate without
336    // becoming unreadable.
337    //
338    // A `running` job has no `rc`, so the orphan pass would match it by age
339    // alone. Skip live `coop-{id}` sessions: keep_days can be 1, which makes
340    // the orphan horizon four days, and a multi-day job is legitimate work.
341    format!(
342        "root={JOBS_ROOT}; [ ! -d \"$root\" ] || {{ \
343         live=$(tmux -L {} list-sessions -F '#{{session_name}}' 2>/dev/null); \
344         find \"$root\" -mindepth 1 -maxdepth 1 -type d -mtime +{} \
345           -exec test -f '{{}}/rc' \\; -exec rm -rf '{{}}' + ; \
346         find \"$root\" -mindepth 1 -maxdepth 1 -type d -mtime +{orphan_days} \
347           -exec test ! -f '{{}}/rc' \\; -print | while IFS= read -r d; do \
348             id=\"${{d##*/}}\"; \
349             echo \"$live\" | grep -qx \"coop-$id\" && continue; \
350             rm -rf \"$d\"; \
351           done; }}",
352        host.tmux_socket, host.keep_days
353    )
354}
355
356pub fn decode_command(input: &str) -> Result<Vec<u8>> {
357    base64::engine::general_purpose::STANDARD
358        .decode(input)
359        .context("invalid command encoding in ls reply")
360}