Skip to main content

areev_trigger/
render.rs

1//! `areev trigger render` — emit scheduler config, never create it.
2//!
3//! These are **templates, not API clients**. Areev does not hold cloud
4//! credentials and does not create cloud resources: provisioning a Cloud
5//! Scheduler job or an EventBridge rule needs IAM permission to create
6//! resources, which is a large privilege escalation for a memory engine, and it
7//! buys a multi-cloud abstraction treadmill for very little differentiation.
8//! You apply the output with whatever you already use for infrastructure.
9//!
10//! ## The heartbeat is deliberately coarse
11//!
12//! The rendered interval is the greatest common divisor of the declared
13//! intervals, floored at 60s — not the shortest interval. The memory owns the
14//! real cadence, so the heartbeat only has to be fine enough not to add
15//! meaningful lag. Rendering a 30-second cron because one trigger asked for 30
16//! seconds would put the schedule back in the crontab, which is the thing this
17//! feature exists to stop.
18
19use areev_core::types::{Trigger, TriggerKind};
20
21use crate::error::{Result, TriggerError};
22
23/// Never render a heartbeat finer than this. cron cannot express sub-minute
24/// schedules at all, and a finer systemd timer mostly buys wakeups.
25const MIN_HEARTBEAT_SECS: u64 = 60;
26
27/// Pick the heartbeat for a set of declarations.
28pub fn heartbeat_secs(triggers: &[Trigger]) -> u64 {
29    let intervals: Vec<u64> = triggers
30        .iter()
31        .filter(|t| t.enabled)
32        .filter_map(|t| match t.kind {
33            TriggerKind::Interval | TriggerKind::Polling => t.interval_secs.map(u64::from),
34            // A cron trigger's cadence is not a period, so it cannot contribute
35            // a divisor. The floor covers it: cron has minute resolution.
36            _ => None,
37        })
38        .filter(|s| *s > 0)
39        .collect();
40
41    let g = intervals.into_iter().reduce(gcd).unwrap_or(MIN_HEARTBEAT_SECS);
42    g.max(MIN_HEARTBEAT_SECS)
43}
44
45fn gcd(a: u64, b: u64) -> u64 {
46    if b == 0 {
47        a
48    } else {
49        gcd(b, a % b)
50    }
51}
52
53/// What the rendered config should invoke.
54pub struct RenderContext<'a> {
55    pub exe: &'a str,
56    pub db: &'a str,
57    pub ns: &'a str,
58    pub heartbeat_secs: u64,
59    /// Extra flags to pass through, e.g. `--connector-cmd ./gmail.sh`.
60    pub extra_args: &'a str,
61}
62
63impl RenderContext<'_> {
64    fn command(&self) -> String {
65        let mut c = format!("{} trigger run --db {} --ns {}", self.exe, self.db, self.ns);
66        if !self.extra_args.trim().is_empty() {
67            c.push(' ');
68            c.push_str(self.extra_args.trim());
69        }
70        c
71    }
72}
73
74/// Render config for `target`.
75pub fn render(target: &str, ctx: &RenderContext<'_>) -> Result<String> {
76    match target {
77        "cron" => Ok(cron(ctx)),
78        "launchd" => Ok(launchd(ctx)),
79        "systemd" => Ok(systemd(ctx)),
80        "k8s-cronjob" => Ok(k8s(ctx)),
81        other => Err(TriggerError::UnknownTarget { target: other.to_string() }),
82    }
83}
84
85/// Every target `render` accepts.
86pub const TARGETS: &[&str] = &["cron", "launchd", "systemd", "k8s-cronjob"];
87
88fn cron_expression(secs: u64) -> String {
89    // cron has minute resolution and no "every N seconds".
90    let mins = (secs / 60).max(1);
91    if mins == 1 {
92        "* * * * *".to_string()
93    } else if mins < 60 && 60 % mins == 0 {
94        format!("*/{mins} * * * *")
95    } else if mins.is_multiple_of(60) && (mins / 60) < 24 {
96        format!("0 */{} * * *", mins / 60)
97    } else {
98        // A period that does not divide an hour would drift or double-fire at
99        // the wrap. Round down to a divisor rather than emit something subtly
100        // wrong — the memory decides what is due anyway, so a finer heartbeat
101        // is harmless and a wrong one is not.
102        let divisor = [30u64, 20, 15, 12, 10, 6, 5, 4, 3, 2, 1]
103            .into_iter()
104            .find(|d| *d <= mins)
105            .unwrap_or(1);
106        format!("*/{divisor} * * * *")
107    }
108}
109
110fn cron(ctx: &RenderContext<'_>) -> String {
111    format!(
112        "# areev triggers — the memory owns the real cadence; this is only a heartbeat.\n\
113         # Generated by `areev trigger render --target cron`. Apply with `crontab -e`.\n\
114         {} {}\n",
115        cron_expression(ctx.heartbeat_secs),
116        ctx.command()
117    )
118}
119
120fn launchd(ctx: &RenderContext<'_>) -> String {
121    let args: Vec<String> = ctx.command().split_whitespace().map(String::from).collect();
122    let arg_xml: String = args
123        .iter()
124        .map(|a| format!("    <string>{}</string>\n", xml_escape(a)))
125        .collect();
126    format!(
127        r#"<?xml version="1.0" encoding="UTF-8"?>
128<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
129<!-- areev triggers heartbeat. The memory owns the real cadence. -->
130<plist version="1.0">
131<dict>
132  <key>Label</key>
133  <string>ai.areev.triggers</string>
134  <key>ProgramArguments</key>
135  <array>
136{arg_xml}  </array>
137  <key>StartInterval</key>
138  <integer>{secs}</integer>
139  <key>RunAtLoad</key>
140  <true/>
141</dict>
142</plist>
143"#,
144        secs = ctx.heartbeat_secs
145    )
146}
147
148fn systemd(ctx: &RenderContext<'_>) -> String {
149    format!(
150        r#"# areev triggers heartbeat. The memory owns the real cadence.
151# Save as areev-triggers.service and areev-triggers.timer, then:
152#   systemctl --user enable --now areev-triggers.timer
153
154# ---- areev-triggers.service ----
155[Unit]
156Description=Areev trigger evaluation
157
158[Service]
159Type=oneshot
160ExecStart={cmd}
161
162# ---- areev-triggers.timer ----
163[Unit]
164Description=Areev trigger heartbeat
165
166[Timer]
167OnBootSec={secs}s
168OnUnitActiveSec={secs}s
169# Fire on resume if the machine was asleep through a window — the same
170# catch-up posture the evaluator itself takes.
171Persistent=true
172# Spread wakeups deterministically rather than randomly, so behaviour stays
173# reproducible.
174FixedRandomDelay=true
175RandomizedDelaySec={jitter}s
176
177[Install]
178WantedBy=timers.target
179"#,
180        cmd = ctx.command(),
181        secs = ctx.heartbeat_secs,
182        jitter = (ctx.heartbeat_secs / 10).max(1),
183    )
184}
185
186fn k8s(ctx: &RenderContext<'_>) -> String {
187    let args: Vec<String> = ctx.command().split_whitespace().map(String::from).collect();
188    let arg_yaml: String =
189        args.iter().map(|a| format!("            - {}\n", yaml_scalar(a))).collect();
190    format!(
191        r#"# areev triggers heartbeat. The memory owns the real cadence.
192apiVersion: batch/v1
193kind: CronJob
194metadata:
195  name: areev-triggers
196spec:
197  schedule: "{schedule}"
198  # Forbid: a heartbeat that overlaps itself would have several evaluators
199  # racing for the same claims. They would be safe — losers skip — but it is
200  # wasted work, and the evaluator's own concurrency policy is the right place
201  # to express overlap, per trigger.
202  concurrencyPolicy: Forbid
203  # A tick this late is not worth firing: the memory will decide what is due on
204  # the next one anyway.
205  startingDeadlineSeconds: {deadline}
206  successfulJobsHistoryLimit: 1
207  failedJobsHistoryLimit: 3
208  jobTemplate:
209    spec:
210      template:
211        spec:
212          restartPolicy: OnFailure
213          containers:
214          - name: areev
215            image: areev:latest
216            command:
217{arg_yaml}"#,
218        schedule = cron_expression(ctx.heartbeat_secs),
219        deadline = (ctx.heartbeat_secs * 2).max(120),
220    )
221}
222
223fn xml_escape(s: &str) -> String {
224    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
225}
226
227/// Quote anything that is not an unambiguous YAML plain scalar.
228fn yaml_scalar(s: &str) -> String {
229    let plain = !s.is_empty()
230        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
231        && !s.starts_with('-');
232    if plain {
233        s.to_string()
234    } else {
235        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    const WF: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
244
245    fn ctx(secs: u64) -> RenderContext<'static> {
246        RenderContext {
247            exe: "areev",
248            db: "accounting.db",
249            ns: "accounting",
250            heartbeat_secs: secs,
251            extra_args: "--connector-cmd ./gmail.sh",
252        }
253    }
254
255    fn interval(secs: u32) -> Trigger {
256        Trigger::new(TriggerKind::Interval, WF).interval_secs(secs)
257    }
258
259    #[test]
260    fn the_heartbeat_is_the_gcd_not_the_shortest_interval() {
261        // 120 and 300 share 60. Rendering 120 would be finer than needed;
262        // rendering 300 would make the 120s trigger late.
263        let h = heartbeat_secs(&[interval(120), interval(300)]);
264        assert_eq!(h, 60);
265    }
266
267    #[test]
268    fn the_heartbeat_never_goes_below_a_minute() {
269        // Sub-minute is inexpressible in cron and mostly buys wakeups. The
270        // memory decides what is due, so a coarse heartbeat is the point.
271        assert_eq!(heartbeat_secs(&[interval(5), interval(10)]), 60);
272    }
273
274    #[test]
275    fn a_disabled_trigger_does_not_pull_the_heartbeat_down() {
276        let h = heartbeat_secs(&[
277            interval(600),
278            interval(120).enabled(false),
279        ]);
280        assert_eq!(h, 600, "a disabled trigger should not cost wakeups");
281    }
282
283    #[test]
284    fn cron_triggers_do_not_contribute_a_divisor() {
285        // A cron cadence is not a period, so it cannot be gcd'd. The floor
286        // covers it, since cron has minute resolution anyway.
287        let h = heartbeat_secs(&[Trigger::new(TriggerKind::Schedule, WF).cron("0 9 * * *")]);
288        assert_eq!(h, 60);
289    }
290
291    #[test]
292    fn no_triggers_still_renders_a_sane_heartbeat() {
293        assert_eq!(heartbeat_secs(&[]), 60);
294    }
295
296    #[test]
297    fn cron_expressions_stay_expressible() {
298        assert_eq!(cron_expression(60), "* * * * *");
299        assert_eq!(cron_expression(300), "*/5 * * * *");
300        assert_eq!(cron_expression(3600), "0 */1 * * *");
301        // 7 minutes does not divide an hour — it would drift at the wrap, so it
302        // rounds DOWN to something that does rather than emitting a schedule
303        // that is subtly wrong.
304        let seven = cron_expression(420);
305        assert!(seven.starts_with("*/"), "{seven}");
306        let n: u64 = seven.trim_start_matches("*/").split(' ').next().unwrap().parse().unwrap();
307        assert_eq!(60 % n, 0, "the rendered period must divide an hour: {seven}");
308    }
309
310    #[test]
311    fn every_declared_target_renders() {
312        for t in TARGETS {
313            let out = render(t, &ctx(120)).unwrap_or_else(|e| panic!("{t}: {e}"));
314            assert!(!out.trim().is_empty(), "{t} rendered nothing");
315            // launchd splits argv into <string> elements, so the tokens are
316            // asserted individually rather than as one substring.
317            assert!(out.contains("trigger"), "{t} must invoke the evaluator");
318            assert!(out.contains("run"), "{t} must invoke the evaluator");
319            assert!(out.contains("accounting.db"), "{t} must name the memory");
320        }
321    }
322
323    #[test]
324    fn an_unknown_target_is_refused_with_its_code() {
325        let e = render("eventbridge", &ctx(60)).unwrap_err();
326        assert_eq!(e.code(), "TRG-E007");
327    }
328
329    #[test]
330    fn launchd_output_is_well_formed_plist() {
331        let out = render("launchd", &ctx(120)).unwrap();
332        assert!(out.starts_with("<?xml"));
333        assert!(out.contains("<key>StartInterval</key>"));
334        assert!(out.contains("<integer>120</integer>"));
335        assert_eq!(out.matches("<array>").count(), out.matches("</array>").count());
336        assert_eq!(out.matches("<dict>").count(), out.matches("</dict>").count());
337    }
338
339    #[test]
340    fn k8s_quotes_arguments_that_yaml_would_misread() {
341        let out = render("k8s-cronjob", &ctx(120)).unwrap();
342        // A leading dash starts a YAML sequence entry; unquoted `--db` inside a
343        // list item is a parse hazard.
344        assert!(out.contains("\"--db\""), "flags must be quoted:\n{out}");
345        assert!(out.contains("concurrencyPolicy: Forbid"));
346    }
347
348    #[test]
349    fn systemd_asks_for_catch_up_and_deterministic_jitter() {
350        let out = render("systemd", &ctx(300)).unwrap();
351        // Persistent=true is the systemd spelling of the catch-up posture the
352        // evaluator already takes.
353        assert!(out.contains("Persistent=true"));
354        // Deterministic rather than random, so behaviour stays reproducible.
355        assert!(out.contains("FixedRandomDelay=true"));
356        assert!(out.contains("OnUnitActiveSec=300s"));
357    }
358
359    #[test]
360    fn extra_args_reach_the_rendered_command() {
361        for t in TARGETS {
362            let out = render(t, &ctx(60)).unwrap();
363            assert!(out.contains("gmail.sh"), "{t} dropped the connector command");
364        }
365    }
366}