BlueGit

golden-age paperdoll-renderer

Merge feature/discard-stale-builds (Tim t999u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
f722dd61aedcc334c09140aa9ecf4c7932f6f582
Pipeline
Passed
3 files changed +99 −1
README.md +1 −1
@@ -150,7 +150,7 @@ Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de re
150 150
151 151 ### Hervatten
152 152
153 Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de resultaatmap blijft onaangeroerd tot de run klaar is.
153 Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de resultaatmap blijft onaangeroerd tot de run klaar is. Start je een nieuwe (niet hervatte) render van hetzelfde geslacht en settype, dan worden oudere onvoltooide `inprogress-*`-mappen van dat doel eerst verwijderd, zodat alleen de nieuwste overblijft; de voltooide resultaatmap, andere doelen en een map waarin op dat moment gerenderd wordt blijven staan.
154 154
155 155 - Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.
156 156 - Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.
@@ -150,7 +150,7 @@ Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de re
150
150
151### Hervatten
151### Hervatten
152
152
153Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de resultaatmap blijft onaangeroerd tot de run klaar is.
153Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de resultaatmap blijft onaangeroerd tot de run klaar is. Start je een nieuwe (niet hervatte) render van hetzelfde geslacht en settype, dan worden oudere onvoltooide `inprogress-*`-mappen van dat doel eerst verwijderd, zodat alleen de nieuwste overblijft; de voltooide resultaatmap, andere doelen en een map waarin op dat moment gerenderd wordt blijven staan.
154
154
155- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.
155- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.
156- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.
156- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.
src/production.rs +60 −0
@@ -1558,6 +1558,13 @@ fn execute_target(
1558 1558 }
1559 1559 let mut session = match target {
1560 1560 RunTarget::Fresh => {
1561 for name in crate::resume::discard_stale(&job.output, &job.sex, &set)? {
1562 emit(RenderEvent::Step {
1563 index: 0,
1564 total: plan.passes,
1565 label: format!("Oude onvoltooide render verwijderd: {name}"),
1566 });
1567 }
1561 1568 let stamp = std::time::SystemTime::now()
1562 1569 .duration_since(std::time::UNIX_EPOCH)
1563 1570 .map_err(|e| e.to_string())?
@@ -2821,6 +2828,59 @@ mod tests {
2821 2828 assert!(gone.is_err());
2822 2829 }
2823 2830
2831 #[test]
2832 #[cfg(unix)]
2833 fn fresh_render_discards_older_unfinished_builds_of_the_same_target_only() {
2834 let tmp = Temp::new();
2835 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2836 let done = tmp.0.join("male-body-atlas");
2837 let done_tree = tree(&done);
2838 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
2839 .0
2840 .is_err());
2841 let first: Vec<PathBuf> = runs(&tmp.0).into_iter().filter(|r| *r != done).collect();
2842 assert_eq!(first.len(), 1);
2843 // Another target and a locked build of this target must survive.
2844 let other = tmp
2845 .0
2846 .join(crate::resume::in_progress_name(1, 1, "female", "body"));
2847 std::fs::create_dir(&other).unwrap();
2848 std::fs::write(
2849 other.join("render.json"),
2850 br#"{"sex":"female","set":"body"}"#,
2851 )
2852 .unwrap();
2853 let locked = tmp
2854 .0
2855 .join(crate::resume::in_progress_name(2, 2, "male", "body"));
2856 std::fs::create_dir(&locked).unwrap();
2857 let lock = std::fs::File::create(locked.join(crate::resume::LOCK)).unwrap();
2858 lock.try_lock().unwrap();
2859 std::thread::sleep(std::time::Duration::from_millis(2));
2860 let (second, events) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10));
2861 assert!(second.is_err());
2862 assert!(!first[0].exists(), "older cancelled build kept");
2863 assert!(other.exists() && locked.exists());
2864 assert!(events.iter().any(|e| matches!(e,
2865 RenderEvent::Step { label, .. } if label.contains("Oude onvoltooide render verwijderd"))));
2866 assert_eq!(tree(&done), done_tree, "finished result touched");
2867 drop(lock);
2868 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2869 let offered = crate::resume::candidates(&tmp.0, &doc).unwrap();
2870 let newest = offered.iter().find(|c| c.journaled > 0).unwrap();
2871 assert!(newest.dir.exists() && newest.dir != locked && newest.dir != first[0]);
2872 // Resuming deletes nothing.
2873 let before = runs(&tmp.0);
2874 drive(
2875 resume_job(&tmp.0),
2876 RunTarget::Resume(newest.dir.clone()),
2877 Some(1),
2878 )
2879 .0
2880 .unwrap_err();
2881 assert_eq!(runs(&tmp.0), before);
2882 }
2883
2824 2884 #[test]
2825 2885 #[cfg(unix)]
2826 2886 fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {
@@ -1558,6 +1558,13 @@ fn execute_target(
1558 }
1558 }
1559 let mut session = match target {
1559 let mut session = match target {
1560 RunTarget::Fresh => {
1560 RunTarget::Fresh => {
1561 for name in crate::resume::discard_stale(&job.output, &job.sex, &set)? {
1562 emit(RenderEvent::Step {
1563 index: 0,
1564 total: plan.passes,
1565 label: format!("Oude onvoltooide render verwijderd: {name}"),
1566 });
1567 }
1561 let stamp = std::time::SystemTime::now()
1568 let stamp = std::time::SystemTime::now()
1562 .duration_since(std::time::UNIX_EPOCH)
1569 .duration_since(std::time::UNIX_EPOCH)
1563 .map_err(|e| e.to_string())?
1570 .map_err(|e| e.to_string())?
@@ -2821,6 +2828,59 @@ mod tests {
2821 assert!(gone.is_err());
2828 assert!(gone.is_err());
2822 }
2829 }
2823
2830
2831 #[test]
2832 #[cfg(unix)]
2833 fn fresh_render_discards_older_unfinished_builds_of_the_same_target_only() {
2834 let tmp = Temp::new();
2835 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2836 let done = tmp.0.join("male-body-atlas");
2837 let done_tree = tree(&done);
2838 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
2839 .0
2840 .is_err());
2841 let first: Vec<PathBuf> = runs(&tmp.0).into_iter().filter(|r| *r != done).collect();
2842 assert_eq!(first.len(), 1);
2843 // Another target and a locked build of this target must survive.
2844 let other = tmp
2845 .0
2846 .join(crate::resume::in_progress_name(1, 1, "female", "body"));
2847 std::fs::create_dir(&other).unwrap();
2848 std::fs::write(
2849 other.join("render.json"),
2850 br#"{"sex":"female","set":"body"}"#,
2851 )
2852 .unwrap();
2853 let locked = tmp
2854 .0
2855 .join(crate::resume::in_progress_name(2, 2, "male", "body"));
2856 std::fs::create_dir(&locked).unwrap();
2857 let lock = std::fs::File::create(locked.join(crate::resume::LOCK)).unwrap();
2858 lock.try_lock().unwrap();
2859 std::thread::sleep(std::time::Duration::from_millis(2));
2860 let (second, events) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10));
2861 assert!(second.is_err());
2862 assert!(!first[0].exists(), "older cancelled build kept");
2863 assert!(other.exists() && locked.exists());
2864 assert!(events.iter().any(|e| matches!(e,
2865 RenderEvent::Step { label, .. } if label.contains("Oude onvoltooide render verwijderd"))));
2866 assert_eq!(tree(&done), done_tree, "finished result touched");
2867 drop(lock);
2868 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2869 let offered = crate::resume::candidates(&tmp.0, &doc).unwrap();
2870 let newest = offered.iter().find(|c| c.journaled > 0).unwrap();
2871 assert!(newest.dir.exists() && newest.dir != locked && newest.dir != first[0]);
2872 // Resuming deletes nothing.
2873 let before = runs(&tmp.0);
2874 drive(
2875 resume_job(&tmp.0),
2876 RunTarget::Resume(newest.dir.clone()),
2877 Some(1),
2878 )
2879 .0
2880 .unwrap_err();
2881 assert_eq!(runs(&tmp.0), before);
2882 }
2883
2824 #[test]
2884 #[test]
2825 #[cfg(unix)]
2885 #[cfg(unix)]
2826 fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {
2886 fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {
src/resume.rs +38 −0
@@ -559,6 +559,44 @@ pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandida
559 559 Ok(found)
560 560 }
561 561
562 /// Delete unfinished (cancelled/failed/interrupted), unlocked `inprogress-*`
563 /// builds of `sex`/`set` before a fresh render (Tim t999u). Completed or
564 /// locked builds, other targets and result folders are never touched.
565 /// Returns the removed folder names.
566 pub fn discard_stale(output: &Path, sex: &str, set: &str) -> Result<Vec<String>, String> {
567 let read = match fs::read_dir(output) {
568 Ok(read) => read,
569 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
570 Err(e) => return Err(e.to_string()),
571 };
572 let suffix = format!("-{sex}-{set}");
573 let mut removed = Vec::new();
574 for entry in read.take(MAX_SCANNED_RUNS).flatten() {
575 let name = entry.file_name().to_string_lossy().to_string();
576 let Ok(kind) = entry.file_type() else {
577 continue;
578 };
579 if !kind.is_dir() || !name.starts_with(IN_PROGRESS_PREFIX) {
580 continue;
581 }
582 let dir = entry.path();
583 let same_target = match read_render(&dir) {
584 Ok(doc) => doc["sex"].as_str() == Some(sex) && doc["set"].as_str() == Some(set),
585 Err(_) => name.ends_with(&suffix),
586 };
587 if !same_target || is_complete(&dir) || is_locked(&dir) {
588 continue;
589 }
590 fs::remove_dir_all(&dir).map_err(|e| format!("oude onvoltooide render {name}: {e}"))?;
591 removed.push(name);
592 }
593 removed.sort();
594 if !removed.is_empty() {
595 sync_dir(output);
596 }
597 Ok(removed)
598 }
599
562 600 /// Fixed result folder name: `<sex>-<settype>-atlas`.
563 601 pub fn result_dir_name(sex: &str, set: &str) -> String {
564 602 format!("{sex}-{set}-atlas")
@@ -559,6 +559,44 @@ pub fn candidates(output: &Path, render_doc: &Value) -> Result<Vec<ResumeCandida
559 Ok(found)
559 Ok(found)
560}
560}
561
561
562/// Delete unfinished (cancelled/failed/interrupted), unlocked `inprogress-*`
563/// builds of `sex`/`set` before a fresh render (Tim t999u). Completed or
564/// locked builds, other targets and result folders are never touched.
565/// Returns the removed folder names.
566pub fn discard_stale(output: &Path, sex: &str, set: &str) -> Result<Vec<String>, String> {
567 let read = match fs::read_dir(output) {
568 Ok(read) => read,
569 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
570 Err(e) => return Err(e.to_string()),
571 };
572 let suffix = format!("-{sex}-{set}");
573 let mut removed = Vec::new();
574 for entry in read.take(MAX_SCANNED_RUNS).flatten() {
575 let name = entry.file_name().to_string_lossy().to_string();
576 let Ok(kind) = entry.file_type() else {
577 continue;
578 };
579 if !kind.is_dir() || !name.starts_with(IN_PROGRESS_PREFIX) {
580 continue;
581 }
582 let dir = entry.path();
583 let same_target = match read_render(&dir) {
584 Ok(doc) => doc["sex"].as_str() == Some(sex) && doc["set"].as_str() == Some(set),
585 Err(_) => name.ends_with(&suffix),
586 };
587 if !same_target || is_complete(&dir) || is_locked(&dir) {
588 continue;
589 }
590 fs::remove_dir_all(&dir).map_err(|e| format!("oude onvoltooide render {name}: {e}"))?;
591 removed.push(name);
592 }
593 removed.sort();
594 if !removed.is_empty() {
595 sync_dir(output);
596 }
597 Ok(removed)
598}
599
562/// Fixed result folder name: `<sex>-<settype>-atlas`.
600/// Fixed result folder name: `<sex>-<settype>-atlas`.
563pub fn result_dir_name(sex: &str, set: &str) -> String {
601pub fn result_dir_name(sex: &str, set: &str) -> String {
564 format!("{sex}-{set}-atlas")
602 format!("{sex}-{set}-atlas")