1use std::collections::HashSet;
5use std::fmt;
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use jiff::tz::TimeZone;
10use jiff::{SignedDuration, Timestamp};
11use ravenrs_report::Report;
12use tokio::sync::Semaphore;
13use tokio::task::JoinHandle;
14
15use crate::config::Config;
16use crate::cron::Schedule;
17use crate::deliver::{Mail, Mailer, write_to_folder};
18use crate::job::{Delivery, Job};
19use crate::render::{Rendered, local_date_time, parameter_values, render, resolve_data};
20use crate::store::{Delivered, JobState, RunRecord, RunStatus, Store};
21
22struct RunningGuard {
24 running: Arc<Mutex<HashSet<String>>>,
25 id: String,
26}
27
28impl Drop for RunningGuard {
29 fn drop(&mut self) {
30 if let Ok(mut running) = self.running.lock() {
31 running.remove(&self.id);
32 }
33 }
34}
35
36#[derive(Clone, Debug)]
38enum Pending {
39 Email { to: Vec<String>, subject: Option<String>, body: Option<String> },
40 Folder { path: String },
41}
42
43struct Progress {
46 rendered: Option<Rendered>,
47 layout_version: Option<u32>,
48 pending: Vec<Pending>,
49 delivered: Vec<Delivered>,
50 errors: Vec<String>,
51 skipped_empty: bool,
52 stopped: bool,
54}
55
56fn pending_deliveries(job: &Job) -> Vec<Pending> {
57 let mut pending = Vec::new();
58 for delivery in &job.deliveries {
59 match delivery {
60 Delivery::Email { to, separately: true, subject, body } => pending.extend(to.iter().map(|one| {
61 Pending::Email { to: vec![one.clone()], subject: subject.clone(), body: body.clone() }
62 })),
63 Delivery::Email { to, subject, body, .. } => {
64 pending.push(Pending::Email { to: to.clone(), subject: subject.clone(), body: body.clone() });
65 }
66 Delivery::Folder { path } => pending.push(Pending::Folder { path: path.clone() }),
67 }
68 }
69 pending
70}
71
72fn fill(template: &str, report: &str, date: &str) -> String {
73 template.replace("{report}", report).replace("{date}", date)
74}
75
76#[derive(Clone)]
78pub struct Runner {
79 store: Store,
80 config: Arc<Config>,
81 mailer: Option<Arc<dyn Mailer>>,
82 permits: Arc<Semaphore>,
83 running: Arc<Mutex<HashSet<String>>>,
84}
85
86impl fmt::Debug for Runner {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 f.debug_struct("Runner").field("store", &self.store).finish_non_exhaustive()
89 }
90}
91
92impl Runner {
93 pub fn new(store: Store, config: Arc<Config>, mailer: Option<Arc<dyn Mailer>>) -> Self {
95 let permits = Arc::new(Semaphore::new(config.max_concurrent_renders.max(1)));
96 Self { store, config, mailer, permits, running: Arc::new(Mutex::new(HashSet::new())) }
97 }
98
99 pub fn permits(&self) -> Arc<Semaphore> {
101 self.permits.clone()
102 }
103
104 pub async fn run_forever(&self, period: Duration) {
106 let mut interval = tokio::time::interval(period);
107 loop {
108 interval.tick().await;
109 self.tick(Timestamp::now());
110 }
111 }
112
113 pub fn tick(&self, now: Timestamp) -> Vec<JoinHandle<RunRecord>> {
117 let ids = match self.store.job_ids() {
118 Ok(ids) => ids,
119 Err(e) => {
120 tracing::error!(error = %e, "could not list jobs");
121 return Vec::new();
122 }
123 };
124 let mut started = Vec::new();
125 for id in ids {
126 match self.check_job(&id, now) {
127 Ok(Some(handle)) => started.push(handle),
128 Ok(None) => {}
129 Err(e) => tracing::error!(job = %id, error = %e, "could not check the job"),
130 }
131 }
132 started
133 }
134
135 fn check_job(&self, id: &str, now: Timestamp) -> Result<Option<JoinHandle<RunRecord>>, String> {
136 let Some(job) = self.store.job(id).map_err(|e| e.to_string())? else { return Ok(None) };
137 if !job.enabled {
138 return Ok(None);
139 }
140 let schedule = Schedule::parse(&job.schedule)?;
141 let zone = TimeZone::get(&job.timezone).map_err(|e| e.to_string())?;
142 let state = self.store.job_state(id).map_err(|e| e.to_string())?;
143 let next = schedule.next_after(&now.to_zoned(zone)).map(|z| z.timestamp());
144 let Some(due) = state.next_run else {
145 self.store.save_job_state(id, &JobState { next_run: next }).map_err(|e| e.to_string())?;
146 return Ok(None);
147 };
148 if due > now {
149 return Ok(None);
150 }
151 self.store.save_job_state(id, &JobState { next_run: next }).map_err(|e| e.to_string())?;
152 let grace = SignedDuration::from_secs(self.config.misfire_grace_minutes.saturating_mul(60) as i64);
153 if now.duration_since(due) > grace {
154 tracing::warn!(job = %id, due = %due, "the run is too late; recording it as missed");
155 self.record_missed(id, due, now, "the server was not running at the scheduled time");
156 return Ok(None);
157 }
158 let runner = self.clone();
159 let id = id.to_owned();
160 Ok(Some(tokio::spawn(async move { runner.run(&id, &job, due, false).await })))
161 }
162
163 fn record_missed(&self, id: &str, due: Timestamp, now: Timestamp, why: &str) -> RunRecord {
164 let record = RunRecord {
165 id: self.store.new_run_id(now),
166 job: id.to_owned(),
167 layout_version: None,
168 scheduled_for: due,
169 started_at: now,
170 finished_at: now,
171 manual: false,
172 status: RunStatus::Missed,
173 attempts: 0,
174 rows: None,
175 delivered: Vec::new(),
176 errors: vec![why.to_owned()],
177 report_warnings: Vec::new(),
178 documents: Vec::new(),
179 };
180 if let Err(e) = self.store.save_run(&record, self.config.history_limit) {
181 tracing::error!(job = %id, error = %e, "could not save the run");
182 }
183 record
184 }
185
186 pub async fn run_now(&self, id: &str, job: &Job) -> RunRecord {
188 self.run(id, job, Timestamp::now(), true).await
189 }
190
191 async fn run(&self, id: &str, job: &Job, scheduled_for: Timestamp, manual: bool) -> RunRecord {
192 let started_at = Timestamp::now();
193 let free = self.running.lock().is_ok_and(|mut running| running.insert(id.to_owned()));
194 if !free {
195 return self.record_missed(id, scheduled_for, started_at, "the previous run was still going");
196 }
197 let guard = RunningGuard { running: self.running.clone(), id: id.to_owned() };
198 let mut progress = Progress {
199 rendered: None,
200 layout_version: None,
201 pending: pending_deliveries(job),
202 delivered: Vec::new(),
203 errors: Vec::new(),
204 skipped_empty: false,
205 stopped: false,
206 };
207 let mut attempts = 0;
208 loop {
209 attempts += 1;
210 progress = self.attempt(job, scheduled_for, progress).await;
211 let done = progress.skipped_empty || (progress.pending.is_empty() && progress.errors.is_empty());
212 if done || progress.stopped || attempts > job.retries {
213 break;
214 }
215 let wait = self.config.retry_delay_seconds.saturating_mul(1 << (attempts - 1).min(16));
216 tracing::warn!(job = %id, attempt = attempts, errors = ?progress.errors, wait, "the run failed; trying again");
217 tokio::time::sleep(Duration::from_secs(wait)).await;
218 }
219 drop(guard);
220 let record = self.finish(id, job, scheduled_for, started_at, manual, attempts, progress);
221 tracing::info!(job = %id, run = %record.id, status = ?record.status, "run finished");
222 record
223 }
224
225 async fn attempt(&self, job: &Job, scheduled_for: Timestamp, progress: Progress) -> Progress {
226 let permit = self.permits.clone().acquire_owned().await;
227 let runner = self.clone();
228 let task_job = job.clone();
229 let task = tokio::task::spawn_blocking(move || {
230 let _permit = permit;
231 runner.attempt_blocking(&task_job, scheduled_for, progress)
232 });
233 match task.await {
234 Ok(progress) => progress,
235 Err(e) => Progress {
237 rendered: None,
238 layout_version: None,
239 pending: Vec::new(),
240 delivered: Vec::new(),
241 errors: vec![format!("the run stopped unexpectedly: {e}")],
242 skipped_empty: false,
243 stopped: true,
244 },
245 }
246 }
247
248 fn attempt_blocking(&self, job: &Job, scheduled_for: Timestamp, mut progress: Progress) -> Progress {
249 progress.errors.clear();
250 let Ok(zone) = TimeZone::get(&job.timezone) else {
251 progress.errors.push(format!("unknown time zone '{}'", job.timezone));
252 return progress;
253 };
254 let local = scheduled_for.to_zoned(zone);
255 let date = local.strftime("%Y-%m-%d").to_string();
256 if progress.rendered.is_none() {
257 match self.render_job(job, &local, &date) {
258 Ok((version, rendered)) => {
259 progress.layout_version = Some(version);
260 progress.rendered = Some(rendered);
261 }
262 Err(e) => {
263 progress.errors.push(e);
264 return progress;
265 }
266 }
267 }
268 let Some(rendered) = progress.rendered.take() else { return progress };
269 if job.skip_if_empty && rendered.rows == 0 {
270 progress.skipped_empty = true;
271 progress.pending.clear();
272 progress.rendered = Some(rendered);
273 return progress;
274 }
275 let mut remaining = Vec::new();
276 for pending in std::mem::take(&mut progress.pending) {
277 match self.deliver(&pending, &rendered, &date) {
278 Ok(delivered) => progress.delivered.push(delivered),
279 Err(e) => {
280 progress.errors.push(e);
281 remaining.push(pending);
282 }
283 }
284 }
285 progress.pending = remaining;
286 progress.rendered = Some(rendered);
287 progress
288 }
289
290 fn render_job(&self, job: &Job, local: &jiff::Zoned, date: &str) -> Result<(u32, Rendered), String> {
291 let (version, text) = self
292 .store
293 .layout(&job.layout, None)
294 .map_err(|e| e.to_string())?
295 .ok_or_else(|| format!("the layout '{}' does not exist", job.layout))?;
296 let report = Report::from_json(&text).map_err(|e| format!("layout '{}': {e}", job.layout))?;
297 let now = local_date_time(local).ok_or("the date is out of range")?;
298 let parameters = parameter_values(&job.parameters, now)?;
299 let data = resolve_data(&report, ¶meters, &self.config)?;
300 Ok((version, render(&report, &data, parameters, now, &job.formats, date)?))
301 }
302
303 fn deliver(&self, pending: &Pending, rendered: &Rendered, date: &str) -> Result<Delivered, String> {
304 let files: Vec<String> = rendered.files.iter().map(|f| f.name.clone()).collect();
305 let at = Timestamp::now();
306 match pending {
307 Pending::Email { to, subject, body } => {
308 let mailer = self.mailer.as_ref().ok_or("the server has no mail server configured")?;
309 let target = to.join(", ");
310 let subject = fill(subject.as_deref().unwrap_or("{report} - {date}"), &rendered.title, date);
311 let body = fill(
312 body.as_deref().unwrap_or("{report}, {date}. The report is attached."),
313 &rendered.title,
314 date,
315 );
316 mailer
317 .send(&Mail { to: to.clone(), subject, body, attachments: rendered.files.clone() })
318 .map_err(|e| format!("email to {target}: {e}"))?;
319 Ok(Delivered { kind: "email".to_owned(), target, files, at })
320 }
321 Pending::Folder { path } => {
322 let base = self.config.output_dir.as_ref().ok_or("the server has no outputDir configured")?;
323 write_to_folder(base, path, &rendered.files)?;
324 Ok(Delivered { kind: "folder".to_owned(), target: path.clone(), files, at })
325 }
326 }
327 }
328
329 #[allow(clippy::too_many_arguments, reason = "one private step of a run")]
330 fn finish(
331 &self,
332 id: &str,
333 job: &Job,
334 scheduled_for: Timestamp,
335 started_at: Timestamp,
336 manual: bool,
337 attempts: u32,
338 progress: Progress,
339 ) -> RunRecord {
340 let now = Timestamp::now();
341 let run_id = self.store.new_run_id(started_at);
342 let status = if progress.skipped_empty {
343 RunStatus::SkippedEmpty
344 } else if progress.errors.is_empty() && progress.pending.is_empty() {
345 RunStatus::Success
346 } else if progress.delivered.is_empty() {
347 RunStatus::Failed
348 } else {
349 RunStatus::Partial
350 };
351 let mut documents = Vec::new();
352 if job.retention_days > 0
353 && !progress.skipped_empty
354 && let Some(rendered) = &progress.rendered
355 {
356 for file in &rendered.files {
357 match self.store.save_document(id, &run_id, &file.name, &file.bytes) {
358 Ok(()) => documents.push(file.name.clone()),
359 Err(e) => {
360 tracing::error!(job = %id, file = %file.name, error = %e, "could not keep the file")
361 }
362 }
363 }
364 }
365 let record = RunRecord {
366 id: run_id,
367 job: id.to_owned(),
368 layout_version: progress.layout_version,
369 scheduled_for,
370 started_at,
371 finished_at: now,
372 manual,
373 status,
374 attempts,
375 rows: progress.rendered.as_ref().map(|r| r.rows),
376 delivered: progress.delivered,
377 errors: progress.errors,
378 report_warnings: progress.rendered.map(|r| r.warnings).unwrap_or_default(),
379 documents,
380 };
381 if let Err(e) = self.store.save_run(&record, self.config.history_limit) {
382 tracing::error!(job = %id, error = %e, "could not save the run");
383 }
384 let keep = SignedDuration::from_hours(i64::from(job.retention_days).saturating_mul(24));
385 if let Ok(cutoff) = now.checked_sub(keep)
386 && let Err(e) = self.store.purge_documents(id, cutoff)
387 {
388 tracing::error!(job = %id, error = %e, "could not delete old files");
389 }
390 record
391 }
392}