1use crate::Session;
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use sioul_learn::{Cancel, Progress, Stage};
25use std::path::PathBuf;
26use std::time::{Duration, Instant};
27
28pub(crate) const KINDS: [&str; 2] = ["fetch", "train"];
30const KEPT_DAYS: i64 = 7;
32const GRACE: i64 = 60;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub(crate) enum State {
39 Starting,
41 Running,
42 Done,
44 Failed,
46 Stopped,
48 Died,
50}
51
52impl State {
53 pub(crate) fn as_str(self) -> &'static str {
54 match self {
55 State::Starting => "starting",
56 State::Running => "running",
57 State::Done => "done",
58 State::Failed => "failed",
59 State::Stopped => "stopped",
60 State::Died => "died",
61 }
62 }
63
64 fn ended(self) -> bool {
65 !matches!(self, State::Starting | State::Running)
66 }
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub(crate) struct Step {
72 pub stage: String,
73 pub done: u64,
74 pub total: u64,
75 pub detail: String,
77 pub line: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub(crate) struct Job {
84 pub id: String,
85 pub kind: String,
87 pub args: Vec<String>,
89 pub state: State,
90 #[serde(default)]
92 pub pid: Option<u32>,
93 pub created: i64,
95 #[serde(default)]
96 pub started: Option<i64>,
97 #[serde(default)]
98 pub ended: Option<i64>,
99 pub updated: i64,
100 #[serde(default)]
101 pub progress: Option<Step>,
102 #[serde(default)]
104 pub lines: Vec<String>,
105 #[serde(default)]
106 pub result: Option<Value>,
107 #[serde(default)]
108 pub error: Option<String>,
109 #[serde(default)]
111 pub stop_asked: bool,
112}
113
114fn now() -> i64 {
115 jiff::Timestamp::now().as_second()
116}
117
118pub(crate) fn folder() -> PathBuf {
120 sioul_core::config::state_dir().join("spam").join("jobs")
121}
122
123fn file(id: &str, extension: &str) -> PathBuf {
124 folder().join(format!("{id}.{extension}"))
125}
126
127pub(crate) fn valid(id: &str) -> bool {
130 (1..=64).contains(&id.len()) && !id.starts_with('-') && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
131}
132
133fn private_folder() -> Result<(), String> {
135 let folder = folder();
136 let mut builder = std::fs::DirBuilder::new();
137 builder.recursive(true);
138 #[cfg(unix)]
139 std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
140 builder.create(&folder).map_err(|e| format!("{}: {e}", folder.display()))
141}
142
143fn private_file(path: &std::path::Path, truncate: bool) -> Result<std::fs::File, String> {
145 let mut options = std::fs::OpenOptions::new();
146 options.create(true).write(true).truncate(truncate);
147 #[cfg(unix)]
148 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
149 options.open(path).map_err(|e| format!("{}: {e}", path.display()))
150}
151
152fn write(job: &Job) -> Result<(), String> {
154 use std::io::Write;
155 let path = file(&job.id, "json");
156 let beside = file(&job.id, "json.new");
157 let text = serde_json::to_string_pretty(job).map_err(|e| e.to_string())?;
158 private_file(&beside, true)?.write_all(text.as_bytes()).map_err(|e| format!("{}: {e}", beside.display()))?;
159 std::fs::rename(&beside, &path).map_err(|e| format!("{}: {e}", path.display()))
160}
161
162fn read(id: &str) -> Result<Job, String> {
164 let path = file(id, "json");
165 let text = std::fs::read_to_string(&path).map_err(|_| format!("No job “{id}”."))?;
166 serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
167}
168
169fn held(id: &str) -> bool {
171 let Ok(lock) = std::fs::OpenOptions::new().read(true).open(file(id, "lock")) else { return false };
172 match lock.try_lock() {
173 Ok(()) => {
174 let _ = lock.unlock();
175 false
176 }
177 Err(_) => true,
178 }
179}
180
181fn log_tail(id: &str) -> String {
183 let text = std::fs::read_to_string(file(id, "log")).unwrap_or_default();
184 let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
185 lines[lines.len().saturating_sub(3)..].iter().map(|l| crate::one_line(l)).collect::<Vec<_>>().join(" / ")
186}
187
188pub(crate) fn look(id: &str) -> Result<Job, String> {
192 if !valid(id) {
193 return Err(format!("No job “{}”.", crate::one_line(&id.chars().take(64).collect::<String>())));
194 }
195 let mut job = read(id)?;
196 if !job.state.ended() && !held(id) && !(job.state == State::Starting && now() - job.created < GRACE) {
197 job.state = State::Died;
198 let tail = log_tail(id);
199 job.error = Some(if tail.is_empty() { "Its process ended without a word: killed, or the computer stopped.".to_string() } else { tail });
200 }
201 job.stop_asked |= file(id, "stop").exists();
202 Ok(job)
203}
204
205pub(crate) fn list() -> Vec<Job> {
207 let mut jobs: Vec<Job> = std::fs::read_dir(folder())
208 .into_iter()
209 .flatten()
210 .filter_map(Result::ok)
211 .filter_map(|e| e.file_name().to_str()?.strip_suffix(".json").map(str::to_string))
212 .filter(|id| valid(id))
213 .filter_map(|id| look(&id).ok())
214 .collect();
215 jobs.sort_by(|a, b| b.created.cmp(&a.created).then(b.id.cmp(&a.id)));
216 jobs
217}
218
219pub(crate) fn running(kind: &str) -> Option<Job> {
221 list().into_iter().find(|j| j.kind == kind && !j.state.ended())
222}
223
224fn prune() {
226 let before = now() - KEPT_DAYS * 86_400;
227 for job in list().into_iter().filter(|j| j.state.ended() && j.ended.unwrap_or(j.updated) < before) {
228 for extension in ["json", "lock", "log", "stop", "json.new"] {
229 let _ = std::fs::remove_file(file(&job.id, extension));
230 }
231 }
232}
233
234fn new_id(kind: &str) -> String {
236 use std::hash::{BuildHasher, Hasher};
237 let mut n = std::collections::hash_map::RandomState::new().build_hasher().finish();
238 let letters: String = (0..4)
239 .map(|_| {
240 let letter = char::from(b'a' + (n % 26) as u8);
241 n /= 26;
242 letter
243 })
244 .collect();
245 format!("{}-{kind}-{letters}", jiff::Zoned::now().strftime("%Y%m%d-%H%M%S"))
246}
247
248pub(crate) fn start(s: &Session, kind: &str, args: Vec<String>) -> Result<Job, String> {
253 let job = create(s, kind, args)?;
254 spawn(s, job)
255}
256
257pub(crate) fn create(s: &Session, kind: &str, args: Vec<String>) -> Result<Job, String> {
259 if !KINDS.contains(&kind) {
260 return Err(format!("No such job: “{kind}”."));
261 }
262 private_folder()?;
263 prune();
264 if let Some(job) = running(kind) {
265 return Err(s.say("spam-job-busy", &[("kind", kind.to_string()), ("id", job.id)]));
266 }
267 let created = now();
268 let job = Job {
269 id: new_id(kind),
270 kind: kind.to_string(),
271 args,
272 state: State::Starting,
273 pid: None,
274 created,
275 started: None,
276 ended: None,
277 updated: created,
278 progress: None,
279 lines: Vec::new(),
280 result: None,
281 error: None,
282 stop_asked: false,
283 };
284 write(&job)?;
285 Ok(job)
286}
287
288pub(crate) fn spawn(s: &Session, job: Job) -> Result<Job, String> {
290 let started = (|| -> Result<std::process::Child, String> {
291 let program = std::env::current_exe().map_err(|e| e.to_string())?;
292 let mut command = std::process::Command::new(program);
293 command.arg("--config").arg(&s.config_path).arg("--language").arg(s.tr.language()).args(["spam", job.kind.as_str(), "--job", job.id.as_str()]).args(&job.args);
294 let log = private_file(&file(&job.id, "log"), true)?;
295 command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(log);
296 detach(&mut command);
297 command.spawn().map_err(|e| e.to_string())
298 })();
299 match started {
300 Ok(mut child) => {
301 let pid = child.id();
302 let _ = std::thread::Builder::new().name("spam-job".into()).spawn(move || {
304 let _ = child.wait();
305 });
306 Ok(Job { pid: Some(pid), ..job })
308 }
309 Err(e) => {
310 let ended = now();
311 let failed = Job { state: State::Failed, ended: Some(ended), updated: ended, error: Some(e.clone()), ..job };
312 let _ = write(&failed);
313 Err(e)
314 }
315 }
316}
317
318fn detach(command: &mut std::process::Command) {
320 #[cfg(unix)]
321 std::os::unix::process::CommandExt::process_group(command, 0);
322 #[cfg(windows)]
323 std::os::windows::process::CommandExt::creation_flags(command, 0x0000_0008 | 0x0000_0200);
325}
326
327pub(crate) fn stop(id: &str) -> Result<Job, String> {
329 let job = look(id)?;
330 if !job.state.ended() {
331 private_file(&file(id, "stop"), false)?;
332 }
333 look(id)
334}
335
336pub(crate) struct Running {
340 job: Job,
341 _lock: std::fs::File,
343 said: Option<Instant>,
344 cancel: Cancel,
345}
346
347impl Running {
348 pub(crate) fn open(id: &str) -> Result<Running, String> {
350 if !valid(id) {
351 return Err(format!("No job “{}”.", crate::one_line(id)));
352 }
353 let mut job = read(id)?;
354 let lock = private_file(&file(id, "lock"), false)?;
355 lock.lock().map_err(|e| e.to_string())?;
356 if job.state.ended() {
357 return Err(format!("The job {id} has ended already."));
358 }
359 let started = now();
360 (job.state, job.pid, job.started, job.updated) = (State::Running, Some(std::process::id()), Some(started), started);
361 write(&job)?;
362 let cancel = Cancel::new();
363 let (watched, stop) = (cancel.clone(), file(id, "stop"));
365 let _ = std::thread::Builder::new().name("spam-job-stop".into()).spawn(move || {
366 while !watched.cancelled() {
367 if stop.exists() {
368 watched.cancel();
369 }
370 std::thread::sleep(Duration::from_millis(500));
371 }
372 });
373 Ok(Running { job, _lock: lock, said: None, cancel })
374 }
375
376 pub(crate) fn cancel(&self) -> Cancel {
378 self.cancel.clone()
379 }
380
381 pub(crate) fn see(&mut self, s: &Session, p: &Progress) {
383 let stage = p.stage.as_str().to_string();
384 let new = self.job.progress.as_ref().is_none_or(|old| old.stage != stage || old.detail != p.detail);
385 if !new && self.said.is_some_and(|at| at.elapsed() < Duration::from_secs(1)) {
386 return;
387 }
388 self.said = Some(Instant::now());
389 self.job.progress = Some(Step { stage, done: p.done, total: p.total, detail: crate::one_line(&p.detail), line: step_line(s, p) });
390 self.job.updated = now();
391 self.job.stop_asked |= self.cancel.cancelled();
392 let _ = write(&self.job);
393 }
394
395 pub(crate) fn finish(mut self, outcome: Result<(Vec<String>, Value), String>) -> Result<(), String> {
397 let ended = now();
398 (self.job.ended, self.job.updated) = (Some(ended), ended);
399 match outcome {
400 Ok((lines, data)) => (self.job.state, self.job.lines, self.job.result) = (State::Done, lines, Some(data)),
401 Err(e) => (self.job.state, self.job.error) = (if self.cancel.cancelled() { State::Stopped } else { State::Failed }, Some(e)),
402 }
403 self.job.stop_asked |= self.cancel.cancelled();
404 self.cancel.cancel();
406 let _ = std::fs::remove_file(file(&self.job.id, "stop"));
407 write(&self.job)
408 }
409}
410
411pub(crate) fn step_line(s: &Session, p: &Progress) -> String {
413 let counted = if p.total > 0 { format!(" {} / {}", p.done, p.total) } else { String::new() };
414 match p.stage {
415 Stage::Corpus if p.detail.is_empty() => s.say("spam-fetch-estimate", &[("n", p.total.to_string())]),
416 Stage::Corpus => format!("{}:{counted}", crate::one_line(&p.detail)),
417 stage => format!("{}{counted}", s.tr.text(&format!("spam-stage-{}", stage.as_str()), None)),
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
427 fn ids_name_nothing_else() {
428 assert!(valid("20261007-153012-train-kxqa"));
429 for id in ["", "..", "../x", "a/b", "-x", "x.json", "a b", &"x".repeat(65)] {
430 assert!(!valid(id), "{id}");
431 }
432 let id = new_id("fetch");
433 assert!(valid(&id) && id.contains("-fetch-"), "{id}");
434 }
435}