Skip to main content

layover_tower/
clock.rs

1//! When a scheduled pipeline is next due, and whether it should actually fire.
2//!
3//! # Why a tick can be skipped
4//!
5//! A pipeline whose previous wave has not finished is still working. Starting a second copy means
6//! paying twice for one result and, where agents share a workspace, two of them writing to the
7//! same files. Skipping means being one interval late. For unattended spending those are not
8//! comparable, so the default is to skip — and `overlap = "allow"` opts back in for pipelines
9//! where a second copy is harmless.
10//!
11//! A skip is reported rather than swallowed. A schedule that quietly skips every tick because its
12//! work always overruns looks exactly like a schedule that is running fine, and the difference is
13//! that nothing is happening.
14//!
15//! # Why the next fire is computed from the schedule, not from the last run
16//!
17//! Adding an interval to when the last run *finished* makes the period drift by however long the
18//! work took, so an hourly job slowly becomes a ninety-minute job. Both forms here are computed
19//! from the clock: `cron` by its own definition, and `every` by stepping forward from the previous
20//! due time until it is in the future. Stepping — rather than adding one interval to now — means a
21//! Tower that was asleep for six hours does not fire six times in a row when it wakes.
22
23use std::collections::BTreeMap;
24use std::sync::Mutex;
25use std::time::Duration;
26
27use jiff::Timestamp;
28use layover_core::config::Config;
29use layover_core::pipeline::{PipelineName, Schedule};
30
31/// Why a pipeline that was due did not start.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Skipped {
34    /// Its previous wave is still going.
35    StillWorking {
36        /// Which pipeline.
37        pipeline: PipelineName,
38    },
39}
40
41impl std::fmt::Display for Skipped {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::StillWorking { pipeline } => write!(
45                f,
46                "`{pipeline}` was due but its previous run has not finished; \
47                 set `overlap = \"allow\"` if two at once is safe"
48            ),
49        }
50    }
51}
52
53/// What a tick found.
54#[derive(Debug, Default, PartialEq, Eq)]
55pub struct Due {
56    /// Pipelines that should start now.
57    pub fire: Vec<PipelineName>,
58    /// Pipelines that were due and did not start.
59    pub skipped: Vec<Skipped>,
60}
61
62/// The next due time for every scheduled pipeline.
63#[derive(Debug)]
64pub struct Clock {
65    next: Mutex<BTreeMap<PipelineName, Timestamp>>,
66}
67
68impl Clock {
69    /// Works out when each scheduled pipeline is first due, starting from `now`.
70    ///
71    /// Nothing fires at startup. A Tower restarting is not a reason to run every hourly job
72    /// immediately, and a factory that fired everything on launch would make restarts expensive
73    /// enough to avoid — which is the opposite of what a supervisor wants.
74    #[must_use]
75    pub fn new(config: &Config, now: Timestamp) -> Self {
76        let mut next = BTreeMap::new();
77
78        for (name, pipeline) in &config.pipelines {
79            if let Some(schedule) = pipeline.trigger.schedule()
80                && let Some(at) = next_after(schedule, now)
81            {
82                next.insert(name.clone(), at);
83            }
84        }
85
86        Self {
87            next: Mutex::new(next),
88        }
89    }
90
91    /// Pipelines due at `now`, advancing each past this firing.
92    ///
93    /// `working` answers whether a pipeline's previous wave is still going. It is asked only for
94    /// pipelines that are actually due, so the caller does not pay to look up the rest.
95    pub fn tick(
96        &self,
97        config: &Config,
98        now: Timestamp,
99        working: impl Fn(&PipelineName) -> bool,
100    ) -> Due {
101        let Ok(mut next) = self.next.lock() else {
102            return Due::default();
103        };
104
105        let mut due = Due::default();
106
107        for (name, at) in next.iter_mut() {
108            if *at > now {
109                continue;
110            }
111
112            // Advanced before deciding whether to fire. A skipped tick is still a tick that
113            // happened; leaving the due time in the past would make the next pass fire
114            // immediately, turning one skip into a busy loop.
115            if let Some(schedule) = config
116                .pipelines
117                .get(name)
118                .and_then(|p| p.trigger.schedule())
119                && let Some(following) = next_after(schedule, now)
120            {
121                *at = following;
122            }
123
124            let overlaps = config
125                .pipelines
126                .get(name)
127                .is_some_and(layover_core::pipeline::Pipeline::allows_overlap);
128
129            if overlaps || !working(name) {
130                due.fire.push(name.clone());
131            } else {
132                due.skipped.push(Skipped::StillWorking {
133                    pipeline: name.clone(),
134                });
135            }
136        }
137
138        due
139    }
140
141    /// When a pipeline is next due, for reporting.
142    #[must_use]
143    pub fn next_due(&self, pipeline: &PipelineName) -> Option<Timestamp> {
144        self.next.lock().ok()?.get(pipeline).copied()
145    }
146
147    /// How long to wait before the soonest pipeline is due.
148    ///
149    /// `None` when nothing is scheduled, which is the difference between a Tower that should sleep
150    /// until a clock says otherwise and one that has no clock to wait for.
151    #[must_use]
152    pub fn until_next(&self, now: Timestamp) -> Option<Duration> {
153        let next = self.next.lock().ok()?;
154        let soonest = next.values().min()?;
155
156        Some(
157            soonest
158                .duration_since(now)
159                .try_into()
160                .unwrap_or(Duration::ZERO),
161        )
162    }
163}
164
165/// The first firing of `schedule` strictly after `now`.
166fn next_after(schedule: &Schedule, now: Timestamp) -> Option<Timestamp> {
167    schedule.next_after(now)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use jiff::ToSpan as _;
174
175    fn factory(pipelines: &str) -> Config {
176        let text = format!(
177            r#"
178[layover]
179work_dir = "work"
180
181[defaults]
182runner = "shell"
183
184[runners.shell]
185command = ["echo"]
186
187[agents.worker]
188prompt = "work"
189entry = true
190
191{pipelines}
192"#
193        );
194
195        toml::from_str(&text).expect("the fixture factory parses")
196    }
197
198    fn name(text: &str) -> PipelineName {
199        PipelineName::new(text)
200    }
201
202    fn never_working(_: &PipelineName) -> bool {
203        false
204    }
205
206    #[test]
207    fn nothing_fires_at_startup() {
208        // A Tower restarting is not a reason to run every hourly job at once. If it were,
209        // restarting would be expensive enough to avoid.
210        let config = factory(
211            r#"
212[pipelines.sweep]
213entry = "worker"
214trigger = { every = "1h" }
215"#,
216        );
217
218        let now = Timestamp::now();
219        let clock = Clock::new(&config, now);
220        let due = clock.tick(&config, now, never_working);
221
222        assert!(due.fire.is_empty(), "{:?}", due.fire);
223        assert!(due.skipped.is_empty());
224    }
225
226    #[test]
227    fn an_interval_pipeline_fires_once_its_interval_has_passed() {
228        let config = factory(
229            r#"
230[pipelines.sweep]
231entry = "worker"
232trigger = { every = "1h" }
233"#,
234        );
235
236        let now = Timestamp::now();
237        let clock = Clock::new(&config, now);
238
239        let later = now.checked_add(61_i32.minutes()).expect("in range");
240        let due = clock.tick(&config, later, never_working);
241
242        assert_eq!(due.fire, [name("sweep")]);
243    }
244
245    #[test]
246    fn a_tower_asleep_for_hours_fires_once_on_waking_not_once_per_missed_tick() {
247        // Six hours of catch-up runs is six times the bill for one result nobody was waiting for.
248        let config = factory(
249            r#"
250[pipelines.sweep]
251entry = "worker"
252trigger = { every = "1h" }
253"#,
254        );
255
256        let now = Timestamp::now();
257        let clock = Clock::new(&config, now);
258
259        let much_later = now.checked_add(6_i32.hours()).expect("in range");
260        let due = clock.tick(&config, much_later, never_working);
261
262        assert_eq!(due.fire, [name("sweep")], "one firing, not six");
263    }
264
265    #[test]
266    fn a_pipeline_that_is_still_working_is_skipped_and_says_why() {
267        // Paying twice for one result, and possibly two agents writing to one workspace.
268        let config = factory(
269            r#"
270[pipelines.sweep]
271entry = "worker"
272trigger = { every = "1h" }
273"#,
274        );
275
276        let now = Timestamp::now();
277        let clock = Clock::new(&config, now);
278        let later = now.checked_add(61_i32.minutes()).expect("in range");
279
280        let due = clock.tick(&config, later, |_| true);
281
282        assert!(due.fire.is_empty());
283        assert_eq!(
284            due.skipped,
285            [Skipped::StillWorking {
286                pipeline: name("sweep")
287            }]
288        );
289        assert!(
290            due.skipped[0].to_string().contains("overlap"),
291            "a skip should say how to opt out of it: {}",
292            due.skipped[0]
293        );
294    }
295
296    #[test]
297    fn overlap_allow_starts_a_second_copy_deliberately() {
298        let config = factory(
299            r#"
300[pipelines.sweep]
301entry = "worker"
302trigger = { every = "1h" }
303overlap = "allow"
304"#,
305        );
306
307        let now = Timestamp::now();
308        let clock = Clock::new(&config, now);
309        let later = now.checked_add(61_i32.minutes()).expect("in range");
310
311        let due = clock.tick(&config, later, |_| true);
312
313        assert_eq!(due.fire, [name("sweep")]);
314        assert!(due.skipped.is_empty());
315    }
316
317    #[test]
318    fn a_skipped_tick_still_advances_the_clock() {
319        // Otherwise the due time stays in the past and every following pass fires immediately:
320        // one skip becomes a busy loop.
321        let config = factory(
322            r#"
323[pipelines.sweep]
324entry = "worker"
325trigger = { every = "1h" }
326"#,
327        );
328
329        let now = Timestamp::now();
330        let clock = Clock::new(&config, now);
331        let later = now.checked_add(61_i32.minutes()).expect("in range");
332
333        clock.tick(&config, later, |_| true);
334        let again = clock.tick(&config, later, never_working);
335
336        assert!(
337            again.fire.is_empty(),
338            "the same tick must not fire twice: {:?}",
339            again.fire
340        );
341    }
342
343    #[test]
344    fn a_manual_pipeline_is_never_due() {
345        let config = factory(
346            r#"
347[pipelines.onbehalf]
348entry = "worker"
349"#,
350        );
351
352        let now = Timestamp::now();
353        let clock = Clock::new(&config, now);
354        let far = now.checked_add(30_i32.hours()).expect("in range");
355
356        assert!(clock.tick(&config, far, never_working).fire.is_empty());
357        assert!(clock.until_next(now).is_none(), "nothing to wait for");
358    }
359
360    #[test]
361    fn a_cron_pipeline_is_due_at_its_next_matching_minute() {
362        let config = factory(
363            r#"
364[pipelines.digest]
365entry = "worker"
366trigger = { cron = "* * * * *" }
367"#,
368        );
369
370        let now = Timestamp::now();
371        let clock = Clock::new(&config, now);
372        let next = clock.next_due(&name("digest")).expect("scheduled");
373
374        assert!(next > now, "the next firing is in the future");
375        assert!(
376            next.duration_since(now).as_secs() <= 60,
377            "an every-minute cron should be due within the minute"
378        );
379    }
380
381    #[test]
382    fn the_wait_is_until_the_soonest_pipeline_not_the_first_one_declared() {
383        let config = factory(
384            r#"
385[pipelines.slow]
386entry = "worker"
387trigger = { every = "6h" }
388
389[pipelines.quick]
390entry = "worker"
391trigger = { every = "5m" }
392"#,
393        );
394
395        let now = Timestamp::now();
396        let clock = Clock::new(&config, now);
397
398        let wait = clock.until_next(now).expect("something is scheduled");
399
400        assert!(
401            wait <= Duration::from_mins(5),
402            "should wait for `quick`, not `slow`: {wait:?}"
403        );
404    }
405}