BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / resume.rs

resume.rs

resume.rs 30.0 KiB Download Raw
1//! Recovery of incomplete/cancelled/crashed production runs (design §7/§12.6).
2//!
3//! Every production run directory holds an exclusive `run.lock` while a
4//! worker writes it and an append-only `progress.jsonl` journal with one
5//! `{"path","sha256"}` line per published target PNG (fsynced after the PNG
6//! itself was published). Cell bytes live under [`CHECKPOINT_DIR`] during an
7//! in-progress run (logical journal keys stay `raw files/…` for `.ppda`
8//! identity); the finished result folder keeps only atlases + `.ppda`
9//! (Tim t1065u). A run may be resumed only when its `render.json`
10//! is byte-for-byte the same contract document that the current job would
11//! write (project, sex/set, body/equipment SHA-256, target, FPS, framing,
12//! pivot, camera, clip inventory, maps, counts). Before any rendering every
13//! planned PNG is checked: journaled checkpoint files whose bytes match their
14//! digest are kept untouched; missing ones are regenerated; corrupt files and
15//! foreign links are moved aside (`<name>.corrupt-aNNN-K`, never deleted);
16//! existing files without a journal entry (crash between publish and journal)
17//! are compared with the regenerated bytes and kept when identical.
18use crate::digest;
19use serde_json::Value;
20use std::{
21 collections::{BTreeMap, BTreeSet, HashSet},
22 fs::{self, File, OpenOptions},
23 io::{Read, Write},
24 path::{Path, PathBuf},
25};
26
27pub const JOURNAL: &str = "progress.jsonl";
28pub const LOCK: &str = "run.lock";
29pub const RESUME_LOG: &str = "resume.jsonl";
30/// Prefix of the temporary build folder of a render (also the resume target).
31pub const IN_PROGRESS_PREFIX: &str = "inprogress-";
32/// Prefix of a previous result folder moved aside during the final swap.
33pub const REPLACED_PREFIX: &str = ".replaced-";
34const MAX_RENDER_JSON: u64 = 1024 * 1024;
35/// Logical prefix of per-cell map paths in the journal and `.ppda` (identity).
36/// Finished result folders no longer keep these files (Tim t1065u).
37pub const RAW_DIR: &str = "raw files";
38/// On-disk checkpoint root inside an in-progress run: cells at
39/// `checkpoint/raw files/…` so Resume works without loose finals (Tim t1065u).
40pub const CHECKPOINT_DIR: &str = "checkpoint";
41/// Completion marker of a build; lives only in the in-progress folder.
42pub const COMPLETE_MARKER: &str = "complete.json";
43
44/// Physical path of a journaled cell (`raw files/…`) under the run root.
45pub fn cell_path(root: &Path, logical_rel: &str) -> PathBuf {
46 root.join(CHECKPOINT_DIR).join(logical_rel)
47}
48
49/// Relative path used with [`crate::production::publish_bytes`].
50pub fn cell_rel(logical_rel: &str) -> String {
51 format!("{CHECKPOINT_DIR}/{logical_rel}")
52}
53
54/// Find a cell on disk: legacy `raw files/…` at the run root, else checkpoint/.
55pub fn find_cell_file(root: &Path, logical_rel: &str) -> Option<PathBuf> {
56 let legacy = root.join(logical_rel);
57 if legacy.is_file() {
58 return Some(legacy);
59 }
60 let checkpoint = cell_path(root, logical_rel);
61 checkpoint.is_file().then_some(checkpoint)
62}
63
64/// Remove checkpoint (and any legacy root-level `raw files/`) after atlases
65/// are verified. Finished folders must not keep loose per-view maps.
66pub fn remove_cell_checkpoints(root: &Path) -> Result<(), String> {
67 for name in [CHECKPOINT_DIR, RAW_DIR] {
68 let path = root.join(name);
69 if path.is_dir() {
70 rescue_checkpoint_artifacts(&path, root)?;
71 }
72 match fs::remove_dir_all(&path) {
73 Ok(()) => sync_dir(root),
74 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
75 Err(e) => return Err(format!("checkpoint opschonen ({name}): {e}")),
76 }
77 }
78 Ok(())
79}
80
81/// Move leftover `.corrupt-*` / `.part-*` files to the run root before wiping cells.
82fn rescue_checkpoint_artifacts(from: &Path, root: &Path) -> Result<(), String> {
83 let mut stack = vec![from.to_path_buf()];
84 while let Some(dir) = stack.pop() {
85 for entry in fs::read_dir(&dir).map_err(io)? {
86 let entry = entry.map_err(io)?;
87 let path = entry.path();
88 let file_type = entry.file_type().map_err(io)?;
89 if file_type.is_dir() {
90 stack.push(path);
91 continue;
92 }
93 if !file_type.is_file() {
94 continue;
95 }
96 let name = entry.file_name().to_string_lossy().to_string();
97 let keep = name.contains(".corrupt-a") || name.contains(".part-");
98 if !keep {
99 continue;
100 }
101 let mut dest = root.join(&name);
102 let mut n = 0u32;
103 while dest.exists() {
104 dest = root.join(format!("{name}.rescued-{n}"));
105 n += 1;
106 }
107 fs::rename(&path, &dest).map_err(|e| format!("quarantaine bewaren {name}: {e}"))?;
108 }
109 }
110 Ok(())
111}
112
113fn has_ppda(dir: &Path) -> bool {
114 fs::read_dir(dir).is_ok_and(|read| {
115 read.flatten()
116 .any(|e| e.file_name().to_string_lossy().ends_with("-atlas.ppda"))
117 })
118}
119
120/// True when `dir` holds a completed build: its `complete.json` marker, or a
121/// build already stripped of bookkeeping (no `render.json`, a `.ppda` present).
122pub fn is_complete(dir: &Path) -> bool {
123 fs::symlink_metadata(dir.join(COMPLETE_MARKER)).is_ok()
124 || (fs::symlink_metadata(dir.join("render.json")).is_err() && has_ppda(dir))
125}
126
127/// Remove render/resume/lock bookkeeping from a completed, unlocked build so
128/// the result folder holds only consumer output (`.ppda` + atlases).
129/// The `.ppda` carries everything the reader still needs (including per-cell
130/// SHA-256). Idempotent and crash-safe: `complete.json` is removed last.
131pub fn strip_bookkeeping(root: &Path) -> Result<(), String> {
132 if !is_complete(root) {
133 return Err("opschonen geweigerd: render is niet voltooid".into());
134 }
135 if is_locked(root) {
136 return Err("opschonen geweigerd: run.lock is bezet".into());
137 }
138 for entry in fs::read_dir(root).map_err(io)? {
139 let entry = entry.map_err(io)?;
140 let name = entry.file_name().to_string_lossy().to_string();
141 let bookkeeping = name == "render.json"
142 || name == JOURNAL
143 || name == RESUME_LOG
144 || name == LOCK
145 || (name.starts_with("attempt-") && name.ends_with(".json"));
146 if bookkeeping && entry.file_type().map_err(io)?.is_file() {
147 fs::remove_file(entry.path()).map_err(|e| format!("opschonen {name}: {e}"))?;
148 }
149 }
150 sync_dir(root);
151 match fs::remove_file(root.join(COMPLETE_MARKER)) {
152 Ok(()) => {}
153 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
154 Err(e) => return Err(format!("opschonen complete.json: {e}")),
155 }
156 sync_dir(root);
157 Ok(())
158}
159const MAX_JOURNAL: u64 = 256 * 1024 * 1024;
160const MAX_CANDIDATES: usize = 20;
161const MAX_SCANNED_RUNS: usize = 2000;
162
163/// Outcome of the pre-render verification of a resumed run.
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
165pub struct ResumeReport {
166 pub attempt: u32,
167 pub planned: usize,
168 pub verified: usize,
169 pub missing: usize,
170 pub quarantined: Vec<String>,
171 pub unjournaled: usize,
172 pub damaged_journal_lines: usize,
173 pub previous_marker: Option<String>,
174}
175
176/// Open write session on one run directory (fresh or resumed).
177pub struct RunSession {
178 pub root: PathBuf,
179 pub attempt: u32,
180 /// Planned PNGs already on disk with a verified journal checksum.
181 pub verified: BTreeMap<String, String>,
182 /// Existing regular PNGs without a journal entry; compared on regeneration.
183 pub unjournaled: BTreeSet<String>,
184 pub report: ResumeReport,
185 journal: File,
186 _lock: File,
187}
188
189fn io(e: std::io::Error) -> String {
190 e.to_string()
191}
192
193fn lock_run(root: &Path) -> Result<File, String> {
194 let path = root.join(LOCK);
195 if let Ok(meta) = fs::symlink_metadata(&path) {
196 if !meta.is_file() {
197 return Err("run.lock is geen regulier bestand".into());
198 }
199 }
200 let file = OpenOptions::new()
201 .create(true)
202 .truncate(false)
203 .write(true)
204 .open(&path)
205 .map_err(io)?;
206 match file.try_lock() {
207 Ok(()) => Ok(file),
208 Err(fs::TryLockError::WouldBlock) => {
209 Err("deze run wordt op dit moment al gerenderd (run.lock bezet)".into())
210 }
211 Err(fs::TryLockError::Error(e)) => Err(format!("run.lock: {e}")),
212 }
213}
214
215fn is_locked(root: &Path) -> bool {
216 let Ok(file) = OpenOptions::new().write(true).open(root.join(LOCK)) else {
217 return false;
218 };
219 matches!(file.try_lock(), Err(fs::TryLockError::WouldBlock))
220}
221
222fn open_journal(root: &Path) -> Result<File, String> {
223 let path = root.join(JOURNAL);
224 if let Ok(meta) = fs::symlink_metadata(&path) {
225 if !meta.is_file() {
226 return Err("progress.jsonl is geen regulier bestand".into());
227 }
228 }
229 OpenOptions::new()
230 .create(true)
231 .append(true)
232 .open(path)
233 .map_err(io)
234}
235
236fn read_regular(path: &Path, max: u64) -> Result<Option<Vec<u8>>, String> {
237 let meta = match fs::symlink_metadata(path) {
238 Ok(meta) => meta,
239 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
240 Err(e) => return Err(e.to_string()),
241 };
242 if !meta.is_file() {
243 return Err(format!("{} is geen regulier bestand", path.display()));
244 }
245 if meta.len() > max {
246 return Err(format!("{} groter dan {max} bytes", path.display()));
247 }
248 let mut bytes = Vec::new();
249 File::open(path)
250 .map_err(io)?
251 .take(max + 1)
252 .read_to_end(&mut bytes)
253 .map_err(io)?;
254 if bytes.len() as u64 > max {
255 return Err(format!("{} groter dan {max} bytes", path.display()));
256 }
257 Ok(Some(bytes))
258}
259
260/// Journal entries: last valid entry per path wins; returns (entries, damaged lines, ends with newline).
261pub fn read_journal(root: &Path) -> Result<(BTreeMap<String, String>, usize, bool), String> {
262 let Some(bytes) = read_regular(&root.join(JOURNAL), MAX_JOURNAL)? else {
263 return Ok((BTreeMap::new(), 0, true));
264 };
265 let mut entries = BTreeMap::new();
266 let mut damaged = 0;
267 for line in bytes.split(|b| *b == b'\n') {
268 if line.is_empty() {
269 continue;
270 }
271 let parsed = serde_json::from_slice::<Value>(line).ok().and_then(|v| {
272 let path = v.get("path")?.as_str()?.to_string();
273 let sha = v.get("sha256")?.as_str()?.to_string();
274 digest::is_sha256_hex(&sha).then_some((path, sha))
275 });
276 match parsed {
277 Some((path, sha)) => {
278 entries.insert(path, sha);
279 }
280 None => damaged += 1,
281 }
282 }
283 Ok((entries, damaged, bytes.last().is_none_or(|b| *b == b'\n')))
284}
285
286/// Move a bad file/link aside inside the run without deleting or clobbering anything.
287pub(crate) fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
288 // Source may be a checkpoint cell or a root-level atlas; the rescued copy
289 // always lands at the run root so it survives checkpoint cleanup (t1065u).
290 let path = find_cell_file(root, rel).unwrap_or_else(|| root.join(rel));
291 let base = Path::new(rel)
292 .file_name()
293 .and_then(|n| n.to_str())
294 .ok_or("ongeldige bestandsnaam")?;
295 for n in 0..1000 {
296 let target_name = format!("{base}.corrupt-a{attempt:03}-{n}");
297 let target = root.join(&target_name);
298 match fs::hard_link(&path, &target) {
299 Ok(()) => {
300 fs::remove_file(&path).map_err(io)?;
301 return Ok(target_name);
302 }
303 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
304 Err(e) => return Err(format!("quarantaine van {rel} mislukt: {e}")),
305 }
306 }
307 Err(format!("geen vrije quarantainenaam voor {rel}"))
308}
309
310/// Move a file to a fresh unique name (no clobber), returning the new file name.
311fn archive(root: &Path, name: &str, prefix: &str) -> Result<String, String> {
312 for n in 0..1000 {
313 let target = if n == 0 {
314 format!("{prefix}-{name}")
315 } else {
316 format!("{prefix}-{n}-{name}")
317 };
318 match fs::hard_link(root.join(name), root.join(&target)) {
319 Ok(()) => {
320 fs::remove_file(root.join(name)).map_err(io)?;
321 return Ok(target);
322 }
323 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
324 Err(e) => return Err(format!("archiveren van {name} mislukt: {e}")),
325 }
326 }
327 Err(format!("geen vrije archiefnaam voor {name}"))
328}
329
330/// Top-level keys whose values differ between two JSON objects.
331pub fn contract_differences(existing: &Value, expected: &Value) -> Vec<String> {
332 match (existing.as_object(), expected.as_object()) {
333 (Some(a), Some(b)) => a
334 .keys()
335 .chain(b.keys())
336 .collect::<BTreeSet<_>>()
337 .into_iter()
338 .filter(|k| a.get(*k) != b.get(*k))
339 .cloned()
340 .collect(),
341 _ => vec!["render.json".into()],
342 }
343}
344
345fn normalized(doc: &Value) -> Result<Value, String> {
346 serde_json::from_str(&serde_json::to_string(doc).map_err(|e| e.to_string())?)
347 .map_err(|e| e.to_string())
348}
349
350fn read_render(root: &Path) -> Result<Value, String> {
351 let bytes =
352 read_regular(&root.join("render.json"), MAX_RENDER_JSON)?.ok_or("render.json ontbreekt")?;
353 serde_json::from_slice(&bytes).map_err(|e| format!("render.json: {e}"))
354}
355
356fn check_contract(root: &Path, render_doc: &Value) -> Result<(), String> {
357 let existing = read_render(root)?;
358 let expected = normalized(render_doc)?;
359 if existing != expected {
360 return Err(format!(
361 "huidige instellingen/bronnen passen niet bij deze run (verschil in: {})",
362 contract_differences(&existing, &expected).join(", ")
363 ));
364 }
365 Ok(())
366}
367
368/// Direct `inprogress-*` child directory of the output directory, not a link.
369fn run_root(output: &Path, dir: &Path) -> Result<PathBuf, String> {
370 let meta = fs::symlink_metadata(dir).map_err(io)?;
371 if !meta.is_dir() {
372 return Err("runmap is geen gewone map".into());
373 }
374 let out = output.canonicalize().map_err(io)?;
375 let root = dir.canonicalize().map_err(io)?;
376 let name_ok = root
377 .file_name()
378 .and_then(|n| n.to_str())
379 .is_some_and(|n| n.starts_with(IN_PROGRESS_PREFIX));
380 if root.parent() != Some(out.as_path()) || !name_ok {
381 return Err("runmap ligt niet direct in de uitvoermap".into());
382 }
383 Ok(root)
384}
385
386impl RunSession {
387 /// A new, empty run directory created by the caller.
388 pub fn fresh(root: &Path) -> Result<Self, String> {
389 let lock = lock_run(root)?;
390 let journal = open_journal(root)?;
391 Ok(Self {
392 root: root.to_path_buf(),
393 attempt: 0,
394 verified: BTreeMap::new(),
395 unjournaled: BTreeSet::new(),
396 report: ResumeReport::default(),
397 journal,
398 _lock: lock,
399 })
400 }
401
402 /// Lock, contract-check and verify an incomplete run before re-rendering.
403 /// `planned` lists every relative target PNG of the job in render order.
404 pub fn resume(
405 output: &Path,
406 dir: &Path,
407 render_doc: &Value,
408 planned: &[String],
409 progress: &mut dyn FnMut(usize, usize),
410 ) -> Result<Self, String> {
411 let root = run_root(output, dir)?;
412 if is_complete(&root) {
413 return Err("deze run is al voltooid (complete.json bestaat)".into());
414 }
415 let lock = lock_run(&root)?;
416 check_contract(&root, render_doc)?;
417 let planned_set: HashSet<&str> = planned.iter().map(String::as_str).collect();
418 let (journal_entries, damaged, newline) = read_journal(&root)?;
419 if let Some(unknown) = journal_entries
420 .keys()
421 .find(|k| !planned_set.contains(k.as_str()))
422 {
423 return Err(format!("journal noemt een onbekend pad: {unknown}"));
424 }
425 let previous_attempts = read_regular(&root.join(RESUME_LOG), MAX_RENDER_JSON)?
426 .map(|b| b.split(|c| *c == b'\n').filter(|l| !l.is_empty()).count())
427 .unwrap_or(0);
428 let attempt = previous_attempts as u32 + 1;
429 let mut report = ResumeReport {
430 attempt,
431 planned: planned.len(),
432 damaged_journal_lines: damaged,
433 ..Default::default()
434 };
435 let mut verified = BTreeMap::new();
436 let mut unjournaled = BTreeSet::new();
437 for (i, rel) in planned.iter().enumerate() {
438 if i % 256 == 0 {
439 progress(i, planned.len());
440 }
441 let path = match find_cell_file(&root, rel) {
442 Some(path) => path,
443 None => {
444 // Also treat a bare checkpoint/legacy path that is a non-file specially.
445 let candidate = cell_path(&root, rel);
446 match fs::symlink_metadata(&candidate) {
447 Ok(meta) if meta.is_dir() => {
448 return Err(format!("map staat op de plaats van PNG {rel}"));
449 }
450 Ok(_) => {
451 report.quarantined.push(quarantine(&root, rel, attempt)?);
452 continue;
453 }
454 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
455 report.missing += 1;
456 continue;
457 }
458 Err(e) => return Err(e.to_string()),
459 }
460 }
461 };
462 let meta = fs::symlink_metadata(&path).map_err(io)?;
463 if meta.is_dir() {
464 return Err(format!("map staat op de plaats van PNG {rel}"));
465 }
466 if !meta.is_file() || meta.len() > crate::export_set::MAX_PNG_BYTES {
467 report.quarantined.push(quarantine(&root, rel, attempt)?);
468 continue;
469 }
470 match journal_entries.get(rel) {
471 Some(expected) => {
472 let bytes = fs::read(&path).map_err(io)?;
473 if digest::sha256_hex(&bytes) == *expected {
474 verified.insert(rel.clone(), expected.clone());
475 } else {
476 report.quarantined.push(quarantine(&root, rel, attempt)?);
477 }
478 }
479 None => {
480 unjournaled.insert(rel.clone());
481 }
482 }
483 }
484 progress(planned.len(), planned.len());
485 report.verified = verified.len();
486 report.unjournaled = unjournaled.len();
487 for marker in ["cancelled.json", "failed.json"] {
488 if fs::symlink_metadata(root.join(marker)).is_ok() {
489 let archived = archive(&root, marker, &format!("attempt-{:03}", attempt - 1))?;
490 report.previous_marker = Some(archived);
491 }
492 }
493 let mut journal = open_journal(&root)?;
494 if !newline {
495 journal.write_all(b"\n").map_err(io)?;
496 journal.sync_data().map_err(io)?;
497 }
498 let entry = serde_json::json!({
499 "attempt": attempt,
500 "planned_png_files": report.planned,
501 "verified": report.verified,
502 "missing": report.missing,
503 "quarantined": report.quarantined,
504 "unjournaled_existing": report.unjournaled,
505 "damaged_journal_lines": report.damaged_journal_lines,
506 "previous_marker": report.previous_marker,
507 });
508 let mut log = OpenOptions::new()
509 .create(true)
510 .append(true)
511 .open(root.join(RESUME_LOG))
512 .map_err(io)?;
513 log.write_all(format!("{entry}\n").as_bytes()).map_err(io)?;
514 log.sync_data().map_err(io)?;
515 Ok(Self {
516 root,
517 attempt,
518 verified,
519 unjournaled,
520 report,
521 journal,
522 _lock: lock,
523 })
524 }
525
526 fn record(&mut self, rel: &str, sha: &str) -> Result<(), String> {
527 let line = serde_json::json!({"path": rel, "sha256": sha}).to_string() + "\n";
528 self.journal.write_all(line.as_bytes()).map_err(io)?;
529 self.journal.sync_data().map_err(io)
530 }
531
532 /// Publish encoded PNG bytes for a planned path, never overwriting a file in place.
533 pub fn publish(&mut self, rel: &str, bytes: &[u8]) -> Result<String, String> {
534 if let Some(sha) = self.verified.get(rel) {
535 return Ok(sha.clone());
536 }
537 let sha = digest::sha256_hex(bytes);
538 if self.unjournaled.remove(rel) {
539 let path = cell_path(&self.root, rel);
540 match fs::symlink_metadata(&path) {
541 Ok(meta) if meta.is_file() => {
542 let existing = fs::read(&path).map_err(io)?;
543 if existing == bytes {
544 self.record(rel, &sha)?;
545 return Ok(sha);
546 }
547 let q = quarantine(&self.root, rel, self.attempt)?;
548 self.report.quarantined.push(q);
549 }
550 Ok(_) => {
551 let q = quarantine(&self.root, rel, self.attempt)?;
552 self.report.quarantined.push(q);
553 }
554 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
555 Err(e) => return Err(e.to_string()),
556 }
557 }
558 crate::production::publish_bytes(&self.root, Path::new(&cell_rel(rel)), bytes)?;
559 self.record(rel, &sha)?;
560 Ok(sha)
561 }
562}
563
564/// An incomplete run of the current sex/set found in the output directory.
565#[derive(Debug, Clone, PartialEq, Eq)]
566pub struct ResumeCandidate {
567 pub dir: PathBuf,
568 pub name: String,
569 /// Distinct planned PNGs recorded in the journal (not yet re-verified).
570 pub journaled: usize,
571 pub planned: u64,
572 /// "geannuleerd", "mislukt" or "afgebroken" (no terminal marker: crash/kill).
573 pub state: String,
574 /// Why this run cannot be resumed now; None when resumable.
575 pub blocked: Option<String>,
576}
577
578fn run_stamp(name: &str) -> u128 {
579 name.strip_prefix(IN_PROGRESS_PREFIX)
580 .and_then(|r| r.split('-').next())
581 .and_then(|n| n.parse().ok())
582 .unwrap_or(0)
583}
584
585/// Incomplete runs of the same sex/set as `render_doc`, newest first.
586pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandidate>, String> {
587 let expected = normalized(render_doc)?;
588 let read = match fs::read_dir(output) {
589 Ok(read) => read,
590 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
591 Err(e) => return Err(e.to_string()),
592 };
593 let mut found = Vec::new();
594 for entry in read.take(MAX_SCANNED_RUNS) {
595 let entry = entry.map_err(io)?;
596 let name = entry.file_name().to_string_lossy().to_string();
597 let Ok(kind) = entry.file_type() else {
598 continue;
599 };
600 if !name.starts_with(IN_PROGRESS_PREFIX) || !kind.is_dir() {
601 continue;
602 }
603 let dir = entry.path();
604 if is_complete(&dir) {
605 continue;
606 }
607 let Ok(existing) = read_render(&dir) else {
608 continue;
609 };
610 if existing.get("sex") != expected.get("sex") || existing.get("set") != expected.get("set")
611 {
612 continue;
613 }
614 let state = if dir.join("cancelled.json").exists() {
615 "geannuleerd"
616 } else if dir.join("failed.json").exists() {
617 "mislukt"
618 } else {
619 "afgebroken"
620 };
621 let journaled = read_journal(&dir).map(|(e, _, _)| e.len()).unwrap_or(0);
622 let blocked = if is_locked(&dir) {
623 Some("wordt op dit moment gerenderd".to_string())
624 } else if existing != expected {
625 Some(format!(
626 "past niet meer bij de huidige instellingen/bronnen (verschil in: {})",
627 contract_differences(&existing, &expected).join(", ")
628 ))
629 } else {
630 None
631 };
632 found.push(ResumeCandidate {
633 dir,
634 planned: existing
635 .get("png_files")
636 .and_then(Value::as_u64)
637 .unwrap_or(0),
638 name,
639 journaled,
640 state: state.into(),
641 blocked,
642 });
643 }
644 found.sort_by_key(|c| std::cmp::Reverse(run_stamp(&c.name)));
645 found.truncate(MAX_CANDIDATES);
646 Ok(found)
647}
648
649/// Delete unfinished (cancelled/failed/interrupted), unlocked `inprogress-*`
650/// builds of `sex`/`set` before a fresh render (Tim t999u). Completed or
651/// locked builds, other targets and result folders are never touched.
652/// Returns the removed folder names.
653pub fn discard_stale(output: &Path, sex: &str, set: &str) -> Result<Vec<String>, String> {
654 let read = match fs::read_dir(output) {
655 Ok(read) => read,
656 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
657 Err(e) => return Err(e.to_string()),
658 };
659 let suffix = format!("-{sex}-{set}");
660 let mut removed = Vec::new();
661 for entry in read.take(MAX_SCANNED_RUNS).flatten() {
662 let name = entry.file_name().to_string_lossy().to_string();
663 let Ok(kind) = entry.file_type() else {
664 continue;
665 };
666 if !kind.is_dir() || !name.starts_with(IN_PROGRESS_PREFIX) {
667 continue;
668 }
669 let dir = entry.path();
670 let same_target = match read_render(&dir) {
671 Ok(doc) => doc["sex"].as_str() == Some(sex) && doc["set"].as_str() == Some(set),
672 Err(_) => name.ends_with(&suffix),
673 };
674 if !same_target || is_complete(&dir) || is_locked(&dir) {
675 continue;
676 }
677 fs::remove_dir_all(&dir).map_err(|e| format!("oude onvoltooide render {name}: {e}"))?;
678 removed.push(name);
679 }
680 removed.sort();
681 if !removed.is_empty() {
682 sync_dir(output);
683 }
684 Ok(removed)
685}
686
687/// Fixed result folder name: `<sex>-<settype>-atlas`.
688pub fn result_dir_name(sex: &str, set: &str) -> String {
689 format!("{sex}-{set}-atlas")
690}
691
692/// Name for a new in-progress build folder (stamp first, so resume sorts by age).
693pub fn in_progress_name(stamp: u128, pid: u32, sex: &str, set: &str) -> String {
694 format!("{IN_PROGRESS_PREFIX}{stamp}-{pid}-{sex}-{set}")
695}
696
697#[cfg(unix)]
698fn sync_dir(dir: &Path) {
699 if let Ok(f) = File::open(dir) {
700 let _ = f.sync_all();
701 }
702}
703#[cfg(not(unix))]
704fn sync_dir(_dir: &Path) {}
705
706fn unique_stamp() -> u128 {
707 std::time::SystemTime::now()
708 .duration_since(std::time::UNIX_EPOCH)
709 .map(|d| d.as_nanos())
710 .unwrap_or(0)
711}
712
713/// Replace `output/<final_name>` by the completed build folder `built`.
714///
715/// The previous result is first renamed aside (`.replaced-<name>-<stamp>`),
716/// then the new folder is renamed into place, and only then the old one is
717/// removed. If the second rename fails the old result is restored. A crash in
718/// between is repaired by [`recover`]. The run lock must already be released.
719pub fn promote(output: &Path, built: &Path, final_name: &str) -> Result<PathBuf, String> {
720 let target = output.join(final_name);
721 let retired = match fs::symlink_metadata(&target) {
722 Ok(_) => {
723 let aside = output.join(format!("{REPLACED_PREFIX}{final_name}-{}", unique_stamp()));
724 fs::rename(&target, &aside)
725 .map_err(|e| format!("oude resultaatmap opzijzetten: {e}"))?;
726 Some(aside)
727 }
728 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
729 Err(e) => return Err(e.to_string()),
730 };
731 if let Err(e) = fs::rename(built, &target) {
732 if let Some(aside) = &retired {
733 let _ = fs::rename(aside, &target);
734 }
735 sync_dir(output);
736 return Err(format!("nieuwe resultaatmap plaatsen: {e}"));
737 }
738 sync_dir(output);
739 if let Some(aside) = retired {
740 // Best effort: a leftover is removed by the next `recover`.
741 let _ = fs::remove_dir_all(&aside);
742 }
743 Ok(target)
744}
745
746/// Repair interrupted swaps in `output` (called before each render):
747/// a moved-aside previous result is restored when its result folder is missing
748/// (or removed when the new one is in place), and a fully completed in-progress
749/// folder that never got swapped is promoted. Returns human-readable notes.
750pub fn recover(output: &Path) -> Result<Vec<String>, String> {
751 let mut notes = Vec::new();
752 let read = match fs::read_dir(output) {
753 Ok(read) => read,
754 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(notes),
755 Err(e) => return Err(e.to_string()),
756 };
757 let mut replaced = Vec::new();
758 let mut built = Vec::new();
759 for entry in read.take(MAX_SCANNED_RUNS).flatten() {
760 let name = entry.file_name().to_string_lossy().to_string();
761 let Ok(kind) = entry.file_type() else {
762 continue;
763 };
764 if !kind.is_dir() {
765 continue;
766 }
767 if name.starts_with(REPLACED_PREFIX) {
768 replaced.push(name);
769 } else if name.starts_with(IN_PROGRESS_PREFIX) {
770 built.push(name);
771 }
772 }
773 replaced.sort();
774 for name in replaced {
775 let rest = &name[REPLACED_PREFIX.len()..];
776 let Some((final_name, _)) = rest.rsplit_once('-') else {
777 continue;
778 };
779 let target = output.join(final_name);
780 if fs::symlink_metadata(&target).is_ok() {
781 let _ = fs::remove_dir_all(output.join(&name));
782 } else {
783 fs::rename(output.join(&name), &target).map_err(io)?;
784 notes.push(format!("vorige resultaatmap {final_name} hersteld"));
785 }
786 }
787 built.sort();
788 for name in built {
789 let dir = output.join(&name);
790 if !is_complete(&dir) || is_locked(&dir) {
791 continue;
792 }
793 let Ok(atlas) = crate::atlas::AtlasSet::open_run(&dir) else {
794 continue;
795 };
796 if crate::export_set::ExportRun::open(&dir).is_err() {
797 continue;
798 }
799 strip_bookkeeping(&dir)?;
800 let final_name = result_dir_name(&atlas.ppda.sex, &atlas.ppda.settype);
801 promote(output, &dir, &final_name)?;
802 notes.push(format!(
803 "voltooide render {name} alsnog geplaatst als {final_name}"
804 ));
805 }
806 Ok(notes)
807}