Merge feature/discard-stale-builds (Tim t999u)
@@ -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 re150
150
151
### Hervatten151
### Hervatten152
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
- 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.@@ -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
.02840
.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 = tmp2845
.02846
.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 = tmp2854
.02855
.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
.02880
.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() {@@ -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<ResumeCandida559
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 or564
/// 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
/// Fixed result folder name: `<sex>-<settype>-atlas`.600
/// Fixed result folder name: `<sex>-<settype>-atlas`.563
pub fn result_dir_name(sex: &str, set: &str) -> String {601
pub fn result_dir_name(sex: &str, set: &str) -> String {564
format!("{sex}-{set}-atlas")602
format!("{sex}-{set}-atlas")