| 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. |
| 18 | use crate::digest; |
| 19 | use serde_json::Value; |
| 20 | use std::{ |
| 21 | collections::{BTreeMap, BTreeSet, HashSet}, |
| 22 | fs::{self, File, OpenOptions}, |
| 23 | io::{Read, Write}, |
| 24 | path::{Path, PathBuf}, |
| 25 | }; |
| 26 | |
| 27 | pub const JOURNAL: &str = "progress.jsonl"; |
| 28 | pub const LOCK: &str = "run.lock"; |
| 29 | pub const RESUME_LOG: &str = "resume.jsonl"; |
| 30 | /// Prefix of the temporary build folder of a render (also the resume target). |
| 31 | pub const IN_PROGRESS_PREFIX: &str = "inprogress-"; |
| 32 | /// Prefix of a previous result folder moved aside during the final swap. |
| 33 | pub const REPLACED_PREFIX: &str = ".replaced-"; |
| 34 | const 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). |
| 37 | pub 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). |
| 40 | pub const CHECKPOINT_DIR: &str = "checkpoint"; |
| 41 | /// Completion marker of a build; lives only in the in-progress folder. |
| 42 | pub const COMPLETE_MARKER: &str = "complete.json"; |
| 43 | |
| 44 | /// Physical path of a journaled cell (`raw files/…`) under the run root. |
| 45 | pub 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`]. |
| 50 | pub 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/. |
| 55 | pub 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. |
| 66 | pub 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. |
| 82 | fn 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 | |
| 113 | fn 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). |
| 122 | pub 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. |
| 131 | pub 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 | } |
| 159 | const MAX_JOURNAL: u64 = 256 * 1024 * 1024; |
| 160 | const MAX_CANDIDATES: usize = 20; |
| 161 | const MAX_SCANNED_RUNS: usize = 2000; |
| 162 | |
| 163 | /// Outcome of the pre-render verification of a resumed run. |
| 164 | #[derive(Debug, Clone, Default, PartialEq, Eq)] |
| 165 | pub 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). |
| 177 | pub 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 | |
| 189 | fn io(e: std::io::Error) -> String { |
| 190 | e.to_string() |
| 191 | } |
| 192 | |
| 193 | fn 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 | |
| 215 | fn 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 | |
| 222 | fn 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 | |
| 236 | fn 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). |
| 261 | pub 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. |
| 287 | pub(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. |
| 311 | fn 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. |
| 331 | pub 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 | |
| 345 | fn 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 | |
| 350 | fn 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 | |
| 356 | fn 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. |
| 369 | fn 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 | |
| 386 | impl 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)] |
| 566 | pub 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 | |
| 578 | fn 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. |
| 586 | pub 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. |
| 653 | pub 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`. |
| 688 | pub 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). |
| 693 | pub 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)] |
| 698 | fn sync_dir(dir: &Path) { |
| 699 | if let Ok(f) = File::open(dir) { |
| 700 | let _ = f.sync_all(); |
| 701 | } |
| 702 | } |
| 703 | #[cfg(not(unix))] |
| 704 | fn sync_dir(_dir: &Path) {} |
| 705 | |
| 706 | fn 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. |
| 719 | pub 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. |
| 750 | pub 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 | } |