Skip to main content

sofie_db/worker/
registry.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use sofie_core::registrations::{INTERVALS_ALLOWED, Key, Problem, validate};
5use sofie_core::{Error, Result};
6
7use super::schedules::Schedules;
8use super::trace::Work;
9use crate::heard::{Known, Listeners};
10use crate::worker::Cadence;
11use crate::worker::{JobContext, Worker};
12use crate::{Claimed, Entry, MessageKind, Trigger};
13
14fn describe(trigger: Trigger) -> String {
15    match trigger {
16        Trigger::On(event) => format!("a listener of `{event}`"),
17        Trigger::Every(_) => "a schedule".to_owned(),
18        Trigger::Queued => "a job".to_owned(),
19    }
20}
21
22fn key(entry: &Entry<JobContext>) -> Key<'static> {
23    let every = match entry.trigger {
24        Trigger::Every(Cadence::Interval(interval)) => Some(interval),
25        _ => None,
26    };
27    Key::Job {
28        name: entry.name,
29        every,
30    }
31}
32
33pub fn publish_heard(worker: &Worker) {
34    worker.db.arrivals().heard().add(&worker.registry.known());
35}
36
37#[derive(Default)]
38pub(crate) struct Registry {
39    entries: Vec<Entry<JobContext>>,
40    by_name: HashMap<&'static str, usize>,
41    pub(super) listeners: Arc<Listeners>,
42    pub(super) schedules: Schedules,
43}
44
45impl Registry {
46    pub(super) fn known(&self) -> Known {
47        Known {
48            listeners: (*self.listeners).clone(),
49            jobs: self.by_name.keys().copied().collect(),
50        }
51    }
52
53    pub(crate) fn add(&mut self, entry: Entry<JobContext>) {
54        match entry.trigger {
55            Trigger::On(event) => {
56                Arc::make_mut(&mut self.listeners)
57                    .by_event
58                    .entry(event)
59                    .or_default()
60                    .push(entry.name);
61            }
62            Trigger::Every(cadence) => self.schedules.add(entry.name, cadence),
63            Trigger::Queued => {}
64        }
65        self.by_name.insert(entry.name, self.entries.len());
66        self.entries.push(entry);
67    }
68
69    #[cfg(feature = "contracts")]
70    pub(super) fn add_taking_pruned(&mut self, entry: Entry<JobContext>) {
71        Arc::make_mut(&mut self.listeners)
72            .taking_pruned
73            .insert(entry.name);
74        self.add(entry);
75    }
76
77    /// Before any work, so a worker registered wrongly stops at boot.
78    pub(crate) fn validate(&self) -> Result<()> {
79        let keys: Vec<Key> = self.entries.iter().map(key).collect();
80        let problems: Vec<String> = validate(&keys)
81            .into_iter()
82            .map(|problem| self.describe(problem))
83            .chain(self.schedules.unknown_zones())
84            .collect();
85        if problems.is_empty() {
86            return Ok(());
87        }
88        Err(Error::internal_message(problems.join("; ")))
89    }
90
91    fn describe(&self, problem: Problem) -> String {
92        match problem {
93            Problem::SameJobName(first, second) => format!(
94                "job `{}` is registered twice: as {} and as {}",
95                self.entries[first].name,
96                describe(self.entries[first].trigger),
97                describe(self.entries[second].trigger)
98            ),
99            Problem::Interval(index) => {
100                let Entry { name, trigger, .. } = &self.entries[index];
101                let Trigger::Every(Cadence::Interval(interval)) = trigger else {
102                    unreachable!("only a schedule has an interval")
103                };
104                format!("schedule `{name}` runs every {interval:?}; {INTERVALS_ALLOWED}")
105            }
106            Problem::SamePath(..) | Problem::SameRouteName(..) => {
107                unreachable!("a worker validates no routes")
108            }
109        }
110    }
111
112    pub(crate) fn job(&self, name: &str) -> Option<&Entry<JobContext>> {
113        self.by_name.get(name).map(|index| &self.entries[*index])
114    }
115
116    /// The process's lone listener when this worker has it: it commits with the event, no job row.
117    pub(super) fn only_listener(
118        &self,
119        message: &Claimed,
120        heard: &Listeners,
121    ) -> Option<&'static str> {
122        let taking = heard.taking(&message.name, &message.payload);
123        match (message.kind, taking.as_ref()) {
124            (MessageKind::Event, [only]) if self.job(only).is_some() => Some(only),
125            _ => None,
126        }
127    }
128
129    pub(super) fn work_of(&self, message: &Claimed, only_listener: Option<&str>) -> Work {
130        match message.kind {
131            MessageKind::Event if only_listener.is_some() => Work::Listener,
132            MessageKind::Event => Work::Event,
133            MessageKind::Job => match self.job(&message.name) {
134                Some(job) if matches!(job.trigger, Trigger::On(_)) => Work::Listener,
135                _ => Work::Job,
136            },
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use std::time::Duration;
144
145    use sofie_core::Result;
146
147    use super::*;
148    use crate::erase;
149
150    async fn tidy() -> Result<()> {
151        Ok(())
152    }
153
154    fn entry(name: &'static str, trigger: Trigger) -> Entry<JobContext> {
155        Entry {
156            name,
157            trigger,
158            run: erase(tidy),
159        }
160    }
161
162    fn refusal(entries: Vec<Entry<JobContext>>) -> String {
163        let mut registry = Registry::default();
164        for entry in entries {
165            registry.add(entry);
166        }
167        registry
168            .validate()
169            .expect_err("the registration is refused")
170            .to_string()
171    }
172
173    #[test]
174    fn a_second_job_of_one_name_is_refused() {
175        let refusal = refusal(vec![
176            entry("tidy", Trigger::Every(Duration::from_secs(60).into())),
177            entry("tidy", Trigger::Queued),
178        ]);
179        assert!(
180            refusal.contains("job `tidy` is registered twice: as a schedule and as a job"),
181            "{refusal}"
182        );
183    }
184
185    #[test]
186    fn a_listener_and_a_schedule_cannot_share_a_name() {
187        let refusal = refusal(vec![
188            entry("answer", Trigger::On("test.Ping")),
189            entry("answer", Trigger::Every(Duration::from_secs(60).into())),
190        ]);
191        assert!(
192            refusal.contains(
193                "job `answer` is registered twice: as a listener of `test.Ping` and as a schedule"
194            ),
195            "{refusal}"
196        );
197    }
198
199    #[test]
200    fn a_schedule_more_often_than_once_a_second_is_refused() {
201        let refusal = refusal(vec![entry(
202            "eager",
203            Trigger::Every(Duration::from_millis(10).into()),
204        )]);
205        assert!(
206            refusal.contains("schedule `eager` runs every 10ms; a schedule runs at most once"),
207            "{refusal}"
208        );
209    }
210
211    #[cfg(feature = "local-time")]
212    #[test]
213    fn a_schedule_in_an_unknown_zone_is_refused_at_start() {
214        let at = sofie_values::TimeOfDay::constant("03:00");
215        let refusal = refusal(vec![entry(
216            "nightly",
217            Trigger::Every(crate::worker::WallClock::daily(at, "Mars/Olympus").into()),
218        )]);
219        assert!(
220            refusal
221                .contains("schedule `nightly` cannot run: `Mars/Olympus` is not an IANA time zone"),
222            "{refusal}"
223        );
224    }
225
226    #[test]
227    fn distinct_jobs_are_accepted() {
228        let mut registry = Registry::default();
229        registry.add(entry(
230            "tidy",
231            Trigger::Every(Duration::from_secs(60).into()),
232        ));
233        registry.add(entry("answer", Trigger::On("test.Ping")));
234        assert!(registry.validate().is_ok());
235        assert_eq!(registry.job("answer").map(|job| job.name), Some("answer"));
236    }
237}