1use std::collections::{BTreeSet, HashMap};
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use serde_json::{Map as JsonMap, Value as Json};
16use sha2::Digest;
17
18use crate::codes::Code;
19use crate::engine::Events;
20use crate::error::Error as Fail;
21use crate::project::Project;
22use crate::run::per_row::{RowDest, deliver_row};
23use crate::run_results::Released;
24use crate::runs::BindingRuns;
25
26pub const HELD: &str = "held.json";
28pub const SCHEMA_VERSION: &str = "dre/held/v1";
29pub const RELEASED: &str = "released.json";
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct Held {
34 pub schema_version: String,
35 pub run_id: String,
36 pub entries: Vec<HeldEntry>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct HeldEntry {
43 pub id: String,
45 pub output: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub key: Option<String>,
48 pub profile: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub path: Option<String>,
51 #[serde(default)]
52 pub options: JsonMap<String, Json>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub attach: Vec<HeldFile>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub rate: Option<crate::rate::Rate>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub message: Option<(String, String)>,
62 pub files: Vec<HeldFile>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct HeldFile {
67 pub path: PathBuf,
68 pub sha256: String,
69}
70
71pub fn sha256_file(p: &Path) -> std::io::Result<String> {
72 let bytes = std::fs::read(p)?;
73 Ok(sha2::Sha256::digest(&bytes)
74 .iter()
75 .map(|b| format!("{b:02x}"))
76 .collect())
77}
78
79pub fn held_file(path: &Path) -> HeldFile {
81 HeldFile {
82 path: path.to_path_buf(),
83 sha256: sha256_file(path).unwrap_or_default(),
84 }
85}
86
87pub fn write_held(run_dir: &Path, run_id: &str, entries: &[HeldEntry]) -> std::io::Result<()> {
88 let held = Held {
89 schema_version: SCHEMA_VERSION.into(),
90 run_id: run_id.to_string(),
91 entries: entries.to_vec(),
92 };
93 crate::fs::write_atomic(
94 &run_dir.join(HELD),
95 serde_json::to_string_pretty(&held)?.as_bytes(),
96 )
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101struct ReleasedFile {
102 release_of: String,
103 released: Vec<String>,
104}
105
106#[derive(Debug, Clone, Default)]
108pub struct ReleaseOptions {
109 pub report: String,
110 pub binding: String,
112 pub run_id: String,
113 pub only_keys: Option<Vec<String>>,
114 pub exclude_keys: Option<Vec<String>>,
115 pub by: String,
117 pub rate: Option<crate::rate::Rate>,
119 pub cancel: crate::engine::CancelToken,
121}
122
123#[derive(Debug, Clone, Default)]
125pub struct ReleaseOutcome {
126 pub release_id: String,
127 pub released: Vec<Released>,
128 pub already: usize,
130}
131
132impl ReleaseOutcome {
133 pub fn failed(&self) -> bool {
134 self.released
135 .iter()
136 .any(|r| r.status != "delivered" && r.status != "not_delivered")
137 }
138}
139
140pub fn release(project: &Project, opts: &ReleaseOptions, events: &Events) -> Result<ReleaseOutcome, Fail> {
142 let store = crate::engine::RunStore::new(&project.target_dir);
143 let runs = BindingRuns::new(&store.run_dir(&opts.report, &opts.binding));
144 let run_dir = runs.run_dir(&opts.run_id);
145 let held_path = run_dir.join(HELD);
146 if !run_dir.is_dir() {
147 return Err(Fail::new(
148 Code::ReleaseFailed,
149 format!(
150 "{} [{}] has no run `{}` in the target path (it may have been pruned: keep it with `keep_runs` for the review window; `dre history` lists the runs)",
151 opts.report, opts.binding, opts.run_id
152 ),
153 ));
154 }
155 let held: Held = match std::fs::read_to_string(&held_path) {
156 Ok(t) => {
157 serde_json::from_str(&t).map_err(|e| format!("{} is malformed: {e}", held_path.display()))?
158 }
159 Err(_) => {
160 return Err(Fail::new(
161 Code::ReleaseFailed,
162 format!(
163 "run `{}` has no held deliveries (no {HELD} in its folder)",
164 opts.run_id
165 ),
166 ));
167 }
168 };
169 let mut done: BTreeSet<String> = BTreeSet::new();
171 for id in runs.list() {
173 if let Ok(t) = std::fs::read_to_string(runs.run_dir(&id).join(RELEASED))
174 && let Ok(r) = serde_json::from_str::<ReleasedFile>(&t)
175 && r.release_of == opts.run_id
176 {
177 done.extend(r.released);
178 }
179 }
180 let started = chrono::Utc::now();
181 let release_id = crate::runs::new_run_id(started);
182 let _guard = runs.lock(&release_id, started).map_err(|e| {
183 let why = match e {
184 crate::runs::LockError::Held(h) => format!("{} holds its lock", h.describe()),
185 crate::runs::LockError::Unreadable(p) => format!("its lock file {} is unreadable", p.display()),
186 crate::runs::LockError::Io(e) => format!("its lock can't be taken: {e}"),
187 };
188 Fail::new(
189 Code::RunInProgress,
190 format!(
191 "{} [{}] is busy: {why}; release when it finishes",
192 opts.report, opts.binding
193 ),
194 )
195 })?;
196 let release_dir = runs.run_dir(&release_id);
197 std::fs::create_dir_all(&release_dir).map_err(|e| e.to_string())?;
198 let mut outcome = ReleaseOutcome {
199 release_id: release_id.clone(),
200 ..ReleaseOutcome::default()
201 };
202 let chosen = |e: &HeldEntry| match &e.key {
203 Some(k) => {
204 opts.only_keys.as_ref().is_none_or(|ks| ks.contains(k))
205 && opts.exclude_keys.as_ref().is_none_or(|ks| !ks.contains(k))
206 }
207 None => opts.only_keys.is_none(),
208 };
209 let log = events.plugin_log();
210 let mut plugins: HashMap<String, dre_protocol::host::PluginProcess> = HashMap::new();
211 let pacer = crate::rate::Pacer::default();
212 let mut progress = RowsLeft::new(
213 held.entries.iter().filter(|e| chosen(e) && !done.contains(&e.id)),
214 opts.rate,
215 );
216 progress.start(events);
217 for e in held.entries.iter().filter(|e| chosen(e)) {
218 if done.contains(&e.id) {
219 outcome.already += 1;
220 continue;
221 }
222 let mask = if project.mask_row_values {
224 let mut values: JsonMap<String, Json> = e.options.clone();
225 if let Some(p) = &e.path {
226 values.insert("$path".into(), Json::from(p.clone()));
227 }
228 crate::for_each::RowMask::new(&values, &[])
229 } else {
230 crate::for_each::RowMask::default()
231 };
232 let mut r = Released {
233 id: e.id.clone(),
234 output: e.output.clone(),
235 key: e.key.clone(),
236 profile: e.profile.clone(),
237 status: String::new(),
238 location: None,
239 error: None,
240 };
241 let changed: Vec<String> = e
243 .files
244 .iter()
245 .chain(&e.attach)
246 .filter(|f| sha256_file(&f.path).ok().as_deref() != Some(f.sha256.as_str()))
247 .map(|f| f.path.display().to_string())
248 .collect();
249 if !changed.is_empty() {
250 r.status = "refused".into();
251 r.error = Some(format!(
252 "refused: {} changed (or is gone) since the run rendered it; re-run the report to render it again",
253 changed.join(", ")
254 ));
255 events.warn(&format!(" {}: {}", e.id, r.error.as_deref().unwrap_or_default()));
256 outcome.released.push(r);
257 progress.entry_done(e, true, events);
258 continue;
259 }
260 let dest = RowDest {
261 idx: 0,
262 profile: e.profile.clone(),
263 path: e.path.clone(),
264 options: e.options.clone(),
265 hold: false,
266 rate: None,
267 attach: e.attach.iter().map(|f| f.path.clone()).collect(),
268 attach_missing: false,
269 zip: None,
271 zip_file: None,
272 };
273 if let Some(rate) = opts.rate.or(e.rate)
275 && !pacer.wait(&e.profile, &rate, &opts.cancel)
276 {
277 break;
278 }
279 if opts.cancel.is_cancelled() {
280 break;
281 }
282 let files: Vec<PathBuf> = e.files.iter().map(|f| f.path.clone()).collect();
283 match deliver_row(
284 project,
285 &log,
286 &dest,
287 &files,
288 e.message.as_ref(),
289 &mut plugins,
290 &project.root,
291 ) {
292 Ok(Some(loc)) => {
293 r.status = "delivered".into();
294 r.location = Some(mask.apply(&loc));
295 done.insert(e.id.clone());
296 write_released(&release_dir, &opts.run_id, &done)?;
298 }
299 Ok(None) => r.status = "not_delivered".into(),
300 Err(err) => {
301 let msg = mask.apply(&err.message);
302 r.status = "failed".into();
303 r.error = Some(msg.clone());
304 events.warn(&format!(" {}: {msg}", e.id));
305 }
306 }
307 progress.entry_done(e, r.status == "failed", events);
308 outcome.released.push(r);
309 }
310 progress.finish(events);
311 for (_, p) in plugins {
312 let _ = p.close();
313 }
314 write_released(&release_dir, &opts.run_id, &done)?;
316 let mut record: Json = std::fs::read_to_string(run_dir.join("run_results.json"))
317 .ok()
318 .and_then(|t| serde_json::from_str(&t).ok())
319 .unwrap_or_else(|| serde_json::json!({}));
320 let failed = outcome.failed();
321 if let Json::Object(m) = &mut record {
322 m.insert("run_id".into(), Json::from(release_id.clone()));
323 m.insert(
324 "status".into(),
325 Json::from(if failed { "error" } else { "success" }),
326 );
327 m.insert(
328 "started_at".into(),
329 Json::from(started.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
330 );
331 m.insert(
332 "duration_ms".into(),
333 Json::from((chrono::Utc::now() - started).num_milliseconds().max(0)),
334 );
335 m.insert("release_of".into(), Json::from(opts.run_id.clone()));
336 m.insert("released_by".into(), Json::from(opts.by.clone()));
337 m.insert(
338 "released".into(),
339 serde_json::to_value(&outcome.released).unwrap_or_default(),
340 );
341 m.insert("error".into(), Json::Null);
342 m.insert("error_code".into(), Json::Null);
343 m.insert("error_kind".into(), Json::Null);
344 if failed {
345 m.insert(
346 "error".into(),
347 Json::from("one or more held deliveries failed or were refused"),
348 );
349 m.insert("error_code".into(), Json::from(Code::ReleaseFailed.to_string()));
350 }
351 m.remove("retry_of");
352 }
353 crate::fs::write_atomic(
354 &release_dir.join("run_results.json"),
355 serde_json::to_string_pretty(&record)
356 .unwrap_or_default()
357 .as_bytes(),
358 )
359 .map_err(|e| e.to_string())?;
360 Ok(outcome)
361}
362
363fn write_released(release_dir: &Path, run_id: &str, done: &BTreeSet<String>) -> Result<(), Fail> {
365 let file = ReleasedFile {
366 release_of: run_id.to_string(),
367 released: done.iter().cloned().collect(),
368 };
369 crate::fs::write_atomic(
370 &release_dir.join(RELEASED),
371 serde_json::to_string(&file).unwrap_or_default().as_bytes(),
372 )
373 .map_err(|e| Fail::from(e.to_string()))
374}
375
376struct RowsLeft {
379 outputs: Vec<crate::run::RowProgress>,
380 rows: HashMap<(String, String), (usize, bool)>,
382}
383
384impl RowsLeft {
385 fn label(e: &HeldEntry) -> String {
386 match &e.output {
387 Some(o) => format!("output `{o}`"),
388 None => "output".to_string(),
389 }
390 }
391
392 fn row(e: &HeldEntry) -> (String, String) {
394 (Self::label(e), e.key.clone().unwrap_or_else(|| e.id.clone()))
395 }
396
397 fn new<'a>(pending: impl Iterator<Item = &'a HeldEntry>, rate: Option<crate::rate::Rate>) -> RowsLeft {
398 let mut p = RowsLeft {
399 outputs: Vec::new(),
400 rows: HashMap::new(),
401 };
402 for e in pending {
403 let label = Self::label(e);
404 let slot = p.rows.entry(Self::row(e)).or_insert((0, false));
405 slot.0 += 1;
406 let new_row = slot.0 == 1;
407 match p.outputs.iter_mut().find(|o| o.output == label) {
408 Some(o) => {
409 if new_row {
410 o.total += 1;
411 }
412 o.rate = o.rate.or(rate.or(e.rate));
413 }
414 None => p.outputs.push(crate::run::RowProgress {
415 output: label,
416 done: 0,
417 failed: 0,
418 total: 1,
419 rate: rate.or(e.rate),
420 finished: false,
421 }),
422 }
423 }
424 p
425 }
426
427 fn start(&self, events: &Events) {
428 for o in &self.outputs {
429 events.rows(o.clone());
430 }
431 }
432
433 fn entry_done(&mut self, e: &HeldEntry, failed: bool, events: &Events) {
434 let Some(slot) = self.rows.get_mut(&Self::row(e)) else {
435 return;
436 };
437 slot.0 = slot.0.saturating_sub(1);
438 slot.1 |= failed;
439 if slot.0 > 0 {
440 return;
441 }
442 let row_failed = slot.1;
443 let label = Self::label(e);
444 if let Some(o) = self.outputs.iter_mut().find(|o| o.output == label) {
445 o.done += 1;
446 if row_failed {
447 o.failed += 1;
448 }
449 events.rows(o.clone());
450 }
451 }
452
453 fn finish(&mut self, events: &Events) {
454 for o in &mut self.outputs {
455 o.finished = true;
456 events.rows(o.clone());
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::engine::RunEvent;
465
466 fn entry(output: &str, key: &str, n: usize) -> HeldEntry {
467 HeldEntry {
468 id: format!("{output}/{key}/{n}"),
469 output: Some(output.into()),
470 key: Some(key.into()),
471 profile: "mail".into(),
472 path: None,
473 options: JsonMap::new(),
474 attach: Vec::new(),
475 rate: Some(crate::rate::Rate::parse("50/second").unwrap()),
476 message: None,
477 files: Vec::new(),
478 }
479 }
480
481 #[test]
482 fn a_row_is_done_once_all_its_deliveries_are() {
483 let entries = [
485 entry("email", "a", 0),
486 entry("email", "a", 1),
487 entry("email", "b", 0),
488 ];
489 let (tx, rx) = std::sync::mpsc::channel();
490 let events = Events::new(tx);
491 let mut p = RowsLeft::new(entries.iter(), None);
492 p.start(&events);
493 p.entry_done(&entries[0], false, &events);
494 p.entry_done(&entries[1], true, &events);
495 p.entry_done(&entries[2], false, &events);
496 p.finish(&events);
497 drop(events);
498 let seen: Vec<(u64, u64, u64, bool)> = rx
499 .try_iter()
500 .map(|e| match e {
501 RunEvent::Rows(r) => {
502 assert_eq!(r.output, "output `email`");
503 assert_eq!(r.rate.map(|r| r.shown()).as_deref(), Some("50/second"));
504 (r.done, r.failed, r.total, r.finished)
505 }
506 _ => panic!("only progress"),
507 })
508 .collect();
509 assert_eq!(
511 seen,
512 vec![
513 (0, 0, 2, false),
514 (1, 1, 2, false),
515 (2, 1, 2, false),
516 (2, 1, 2, true)
517 ]
518 );
519 }
520}