Skip to main content

dataflow_rs/program/
compile.rs

1//! The compiler: one walk over a definition that compiles every expression
2//! and config, reports every issue at its authored path, fills the side
3//! tables the ops index, and lowers each step into ops as it reaches it.
4
5use super::checks::{Below, Checks, Hook, Site, Stamp};
6use super::fingerprint::Fingerprint;
7use super::lower::Lower;
8use super::ops::{CallSite, Callee, CondKind, CondSite, FanSite, LoopSite, Op, Policy, TaskMeta};
9use crate::base::any_rejected;
10use crate::base::typo::near_miss;
11use crate::base::{AuthoredPath, Issue, IssueCode, PathSeg, StepKind};
12use crate::def::{
13    self, ForEachDef, GroupDef, Logic, LoopDef, ParseOptions, Rollout, Status, StepDef, TaskDef,
14    WorkflowDef, WorkflowFailurePolicy,
15};
16use crate::expr::path::MAX_PATH_SEGMENTS;
17use crate::expr::{CompileCx, Expr, ExprEngine, Path};
18use crate::function::{Entry, FunctionSpec, Prepare, Registry};
19use datavalue::OwnedDataValue;
20use serde_json::Value;
21use std::fmt;
22use std::str::FromStr;
23use std::sync::Arc;
24
25/// Answers whether a definition that a config names exists, such as a
26/// connector a deployment declares, and why not when it does not.
27///
28/// A [`Compiler`] given one through [`CompileOptions::resources`] checks each
29/// [`Reference`](crate::function::Reference) a host function's spec declares.
30/// A field holding a literal string the oracle does not resolve is an
31/// `UNRESOLVED_REFERENCE` Defect, whose message leads with the oracle's
32/// reason when it gives one. A computed or absent value is not checked.
33///
34/// ```
35/// use dataflow_rs::program::ResourceOracle;
36///
37/// /// The deployment's connectors: `orders` a database, `sessions` a cache.
38/// struct Connectors;
39///
40/// impl ResourceOracle for Connectors {
41///     fn resolve(&self, _kind: &str, name: &str) -> Result<(), String> {
42///         match name {
43///             "orders" => Ok(()),
44///             "sessions" => Err(format!("connector `{name}` is a cache, not a database")),
45///             _ => Err(String::new()),
46///         }
47///     }
48/// }
49/// ```
50pub trait ResourceOracle: Send + Sync + 'static {
51    /// `Ok` when a `kind` named `name` exists: the connector `db1`, say.
52    /// `Err(why)` when it does not. A `why` that is not empty is the issue's
53    /// message, after what did not resolve: `` `sessions` does not resolve
54    /// as a connector: connector `sessions` is a cache, not a database ``.
55    /// An empty one gives the plain message: `` no connector named `db2`
56    /// resolves ``.
57    ///
58    /// # Errors
59    ///
60    /// Why `name` does not resolve, or nothing more to say: an empty string.
61    fn resolve(&self, kind: &str, name: &str) -> std::result::Result<(), String>;
62}
63
64/// How a [`Compiler`] compiles.
65#[derive(Clone, Default)]
66#[non_exhaustive]
67pub struct CompileOptions {
68    /// The oracle the `Reference` fields are checked against. `None`, the
69    /// default, checks nothing.
70    pub resources: Option<Arc<dyn ResourceOracle>>,
71}
72
73impl fmt::Debug for CompileOptions {
74    /// Whether an oracle is set; not the oracle.
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.debug_struct("CompileOptions")
77            .field("resources", &self.resources.is_some())
78            .finish_non_exhaustive()
79    }
80}
81
82impl CompileOptions {
83    /// Checks the `Reference` fields against `oracle`.
84    #[must_use]
85    pub fn resources(mut self, oracle: Arc<dyn ResourceOracle>) -> Self {
86        self.resources = Some(oracle);
87        self
88    }
89}
90
91impl Registry {
92    /// A compiler for this registry's functions, with `options`.
93    pub fn compiler(&self, options: CompileOptions) -> Compiler {
94        Compiler {
95            registry: self.clone(),
96            options,
97        }
98    }
99}
100
101/// Compiles definitions against one [`Registry`], so everything it compiles
102/// shares one set of functions and one vocabulary.
103///
104/// Each workflow compiles on its own: an unregistered function, or any other
105/// Rejected issue, rejects only its own workflow.
106///
107/// ```
108/// use dataflow_rs::Registry;
109/// use dataflow_rs::def::{ParseOptions, parse};
110/// use dataflow_rs::program::{CompileOptions, IssueCode};
111/// use serde_json::json;
112///
113/// let compiler = Registry::default().compiler(CompileOptions::default());
114/// let json = json!({"id": "w", "tasks": [
115///     {"id": "t", "function": {"name": "fetch"}}
116/// ]});
117/// let def = parse(&json, ParseOptions::default()).def.unwrap();
118/// let issues = compiler.compile_one(&def).unwrap_err();
119/// assert_eq!(issues[0].code, IssueCode::UnknownFunction);
120/// assert_eq!(issues[0].path.to_string(), "tasks[0].function.name");
121/// assert_eq!(compiler.check(&def), issues);
122/// ```
123#[derive(Clone, Debug)]
124pub struct Compiler {
125    pub(super) registry: Registry,
126    pub(super) options: CompileOptions,
127}
128
129impl Compiler {
130    /// The registry the compiler resolves functions in.
131    pub fn registry(&self) -> &Registry {
132        &self.registry
133    }
134
135    /// Every issue [`compile_one`](Self::compile_one) finds in `def`, whether
136    /// or not it rejects the workflow.
137    pub fn check(&self, def: &WorkflowDef) -> Vec<Issue> {
138        match self.compile_one(def) {
139            Ok(compiled) => compiled.issues,
140            Err(issues) => issues,
141        }
142    }
143
144    /// Every issue in a fragment of steps that would sit at `root` in a
145    /// workflow, such as `tasks[2].tasks` or `loop.setup`: its parse issues,
146    /// then its compile issues, every path under `root`.
147    ///
148    /// The fragment is parsed as [`parse`](crate::def::parse) reads a
149    /// workflow's steps, with the compat reader on. A root under `loop.setup`
150    /// makes setup steps, and each `tasks` key in `root` past the first adds
151    /// one group of depth. The steps compile only when nothing in the parse is
152    /// Rejected, and a fragment has no workflow condition, loop or workflow
153    /// lint. A host function's `prepare` sees an empty workflow id.
154    ///
155    /// A `root` that is not an authored path is `PARSE_FAILED` at the empty
156    /// path. A root that holds no steps, one neither ending in a `tasks` key
157    /// nor `loop.setup`, is `PARSE_FAILED` at the root, and so is a fragment
158    /// that is not an array.
159    pub fn check_steps(&self, steps: &Value, root: &str) -> Vec<Issue> {
160        let root = match AuthoredPath::from_str(root) {
161            Ok(root) => root,
162            Err(error) => {
163                return vec![Issue::new(
164                    IssueCode::ParseFailed,
165                    AuthoredPath::root(),
166                    error.to_string(),
167                )];
168            }
169        };
170        let Some((setup, _)) = def::fragment_place(&root) else {
171            let message = format!(
172                "`{root}` holds no steps: a fragment sits at a `tasks` list, such as `tasks` or \
173                 `tasks[2].tasks`, or at `loop.setup`"
174            );
175            return vec![Issue::new(IssueCode::ParseFailed, root, message)];
176        };
177        let (parsed, mut issues) = def::parse_steps(steps, &root, ParseOptions::default());
178        if let Some(steps) = parsed {
179            let mut checks = Checks::new(self.registry.exprs());
180            let mut walk = Walk::new(self, "", &mut checks);
181            walk.steps(&steps, &root, setup);
182            issues.append(&mut walk.issues);
183        }
184        issues
185    }
186
187    /// Compiles one workflow.
188    ///
189    /// `Err` holds every issue, in walk order, when any is Rejected. `Ok`
190    /// holds the workflow and its Defects and Advisories, so seeing them
191    /// takes no second compile.
192    pub fn compile_one(&self, def: &WorkflowDef) -> std::result::Result<Compiled, Vec<Issue>> {
193        let mut checks = Checks::new(self.registry.exprs());
194        let (workflow, issues) = self.compile_with(def, &mut checks);
195        if any_rejected(&issues) {
196            return Err(issues);
197        }
198        // O(ops), once per workflow: the interpreter trusts every one.
199        if let Err(broken) = workflow.check_invariants() {
200            panic!(
201                "the lowering of `{}` broke an invariant: {broken}\n{}",
202                workflow.id,
203                workflow.listing(),
204            );
205        }
206        Ok(Compiled { workflow, issues })
207    }
208
209    /// The compile walk, calling `hook` at every expression site.
210    fn compile_with(
211        &self,
212        def: &WorkflowDef,
213        hook: &mut dyn Hook,
214    ) -> (CompiledWorkflow, Vec<Issue>) {
215        let mut walk = Walk::new(self, &def.id, hook);
216        let root = AuthoredPath::root();
217        // 1. The workflow condition: `None` when it is absent or literally
218        //    true, so it lowers to no `Cond`.
219        let cond = def
220            .condition
221            .as_ref()
222            .and_then(|raw| walk.cond(raw, root.join("condition"), None));
223        // 2. The loop: `over`, then its slots. 3. Each step: setup first, then
224        //    the body, pre-order, each lowered as the walk reaches it.
225        let body = root.join("tasks");
226        match &def.r#loop {
227            None => {
228                walk.lower.workflow_cond(cond);
229                walk.steps(&def.tasks, &body, false);
230            }
231            Some(config) => {
232                let lp = walk.r#loop(config);
233                // The pre-phase, before the first sweep: only a loop with
234                // setup or `over` has one.
235                if !config.setup.is_empty() || config.over.is_some() {
236                    walk.lower.workflow_cond(cond);
237                }
238                walk.steps(&config.setup, &root.join("loop").join("setup"), true);
239                let sweep = walk.lower.loop_init(lp);
240                walk.lower.workflow_cond(cond);
241                walk.steps(&def.tasks, &body, false);
242                walk.lower.loop_next(lp, sweep);
243            }
244        }
245        // 4. The workflow lint, `UNGUARDED_VALIDATION`.
246        walk.hook.workflow(def, &mut walk.issues);
247        let workflow = CompiledWorkflow {
248            registry: self.registry.clone(),
249            id: def.id.clone(),
250            channel: def.channel.clone(),
251            priority: def.priority,
252            status: def.status,
253            version: def.version,
254            rollout: def.rollout,
255            on_failure: def.on_failure,
256            ops: walk.lower.finish(),
257            conds: walk.conds,
258            calls: walk.calls,
259            fans: walk.fans,
260            loops: walk.loops,
261            tasks: walk.tasks,
262            fingerprint: walk.hook.fingerprint(),
263        };
264        (workflow, walk.issues)
265    }
266}
267
268/// A workflow [`Compiler::compile_one`] accepted, and what it found.
269#[derive(Debug)]
270#[non_exhaustive]
271pub struct Compiled {
272    /// The compiled workflow.
273    pub workflow: CompiledWorkflow,
274    /// Its Defects and Advisories, in walk order. None is Rejected.
275    pub issues: Vec<Issue>,
276}
277
278/// One workflow, compiled and immutable. `Send + Sync`.
279pub struct CompiledWorkflow {
280    /// The registry it was compiled under: [`Compiler::link`] links it under
281    /// that one only. Holding it keeps its identity from being reused.
282    pub(crate) registry: Registry,
283    pub(crate) id: String,
284    pub(crate) channel: String,
285    pub(crate) priority: u32,
286    pub(crate) status: Status,
287    pub(crate) version: u32,
288    pub(crate) rollout: Option<Rollout>,
289    pub(crate) on_failure: WorkflowFailurePolicy,
290    pub(crate) ops: Vec<Op>,
291    pub(crate) conds: Vec<CondSite>,
292    pub(crate) calls: Vec<CallSite>,
293    pub(crate) fans: Vec<FanSite>,
294    pub(crate) loops: Vec<LoopSite>,
295    pub(crate) tasks: Vec<TaskMeta>,
296    pub(crate) fingerprint: Fingerprint,
297}
298
299impl CompiledWorkflow {
300    /// The workflow's id.
301    pub fn id(&self) -> &str {
302        &self.id
303    }
304
305    /// The workflow's fingerprint: the operators and data keys its
306    /// expressions use.
307    pub fn fingerprint(&self) -> Fingerprint {
308        self.fingerprint
309    }
310}
311
312impl fmt::Debug for CompiledWorkflow {
313    /// The id, the op count and the size of each side table.
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        f.debug_struct("CompiledWorkflow")
316            .field("id", &self.id)
317            .field("ops", &self.ops.len())
318            .field("conds", &self.conds.len())
319            .field("calls", &self.calls.len())
320            .field("fans", &self.fans.len())
321            .field("loops", &self.loops.len())
322            .field("tasks", &self.tasks.len())
323            .finish_non_exhaustive()
324    }
325}
326
327/// Where a task's function is compiled: what [`compile_callee`] needs
328/// beside the function and its input.
329pub(crate) struct CalleeAt<'a> {
330    /// The function's registered name.
331    pub(crate) name: &'a str,
332    /// The workflow's id, which `prepare` is told; empty for a fragment.
333    pub(crate) workflow_id: &'a str,
334    /// The task's step, stamped on every issue.
335    pub(crate) step: (StepKind, &'a str),
336    /// Where the config sits: the task's `function.input`.
337    pub(crate) input_at: &'a AuthoredPath,
338}
339
340/// `entry`'s config from `input`, compiled as a task's at `at`: a host
341/// function's with its spec's runtime fields and a [`Prepare`] naming the
342/// task; a config that refuses its input without recording why reported, as
343/// [`refused`]; then, when it compiled, its expressions walked with `hook`.
344/// The compiler compiles every task's function through it, and
345/// `function::testing` the one function a unit test calls, so a check
346/// added here reaches both.
347pub(crate) fn compile_callee(
348    exprs: &ExprEngine,
349    entry: Entry<'_>,
350    input: &Value,
351    at: &CalleeAt<'_>,
352    hook: &mut dyn Hook,
353    issues: &mut Vec<Issue>,
354) -> Option<Callee> {
355    let callee = {
356        let mut cx = CompileCx::new(exprs, issues)
357            .at_path(at.input_at.clone())
358            .for_step(at.step.0, at.step.1);
359        let before = cx.rejected();
360        let callee = match entry {
361            Entry::Builtin(kind) => kind.compile(input, &mut cx).map(Callee::Builtin),
362            Entry::Host(f) => {
363                if let Some(fields) = &f.spec().runtime_fields {
364                    cx = cx.with_runtime_fields(fields);
365                }
366                let prepare = Prepare::new(at.name, at.workflow_id, at.step.1, f.spec());
367                f.compile(input, &mut cx, &prepare).map(Callee::Host)
368            }
369        };
370        // A hand-written config can refuse without recording why. Its task
371        // would then have no call, so the refusal is reported.
372        if callee.is_none() && cx.rejected() == before {
373            refused(&mut cx, at.name);
374        }
375        callee
376    }?;
377    callee.visit(&mut Below {
378        hook,
379        issues,
380        root: at.input_at,
381        step: Some(at.step),
382    });
383    Some(callee)
384}
385
386/// A config of `name` that refused its input without recording why,
387/// reported, so a rejection always carries an issue.
388pub(crate) fn refused(cx: &mut CompileCx<'_>, name: &str) {
389    cx.issue(
390        IssueCode::InputParse,
391        format!("the config of `{name}` refused its input without reporting why"),
392    );
393}
394
395/// A table's next index.
396pub(super) fn next(len: usize) -> u32 {
397    u32::try_from(len).expect("a workflow holds fewer than 2^32 steps")
398}
399
400/// The value at `path` below `value`, if there is one.
401fn lookup<'v>(value: &'v Value, path: &AuthoredPath) -> Option<&'v Value> {
402    path.segs().iter().try_fold(value, |value, seg| match seg {
403        PathSeg::Key(key) => value.get(&**key),
404        PathSeg::Index(index) => value.get(*index),
405    })
406}
407
408/// One compile's state: the issues so far, in walk order, the side tables,
409/// in walk order, and the ops so far.
410struct Walk<'w> {
411    registry: &'w Registry,
412    resources: Option<&'w dyn ResourceOracle>,
413    /// What `prepare` is told; empty for a fragment.
414    workflow_id: &'w str,
415    hook: &'w mut dyn Hook,
416    issues: Vec<Issue>,
417    conds: Vec<CondSite>,
418    calls: Vec<CallSite>,
419    fans: Vec<FanSite>,
420    loops: Vec<LoopSite>,
421    tasks: Vec<TaskMeta>,
422    lower: Lower,
423}
424
425impl<'w> Walk<'w> {
426    fn new(compiler: &'w Compiler, workflow_id: &'w str, hook: &'w mut dyn Hook) -> Self {
427        Self {
428            registry: &compiler.registry,
429            resources: compiler.options.resources.as_deref(),
430            workflow_id,
431            hook,
432            issues: Vec::new(),
433            conds: Vec::new(),
434            calls: Vec::new(),
435            fans: Vec::new(),
436            loops: Vec::new(),
437            tasks: Vec::new(),
438            lower: Lower::default(),
439        }
440    }
441
442    /// Records `issue`, stamped with `step`.
443    fn report(&mut self, issue: Issue, step: Stamp<'_>) {
444        self.issues.push(match step {
445            Some((kind, id)) => issue.with_step(kind, Some(id)),
446            None => issue,
447        });
448    }
449
450    /// Compiles the expression at `at` and, when it compiled, hands it to the
451    /// hook. A failure is `TEMPLATE_COMPILE` at `at`, and is not visited.
452    fn expr(
453        &mut self,
454        raw: &Logic,
455        at: &AuthoredPath,
456        records: bool,
457        step: Stamp<'_>,
458    ) -> Option<Expr> {
459        match self.registry.exprs().compile::<OwnedDataValue>(&raw.0) {
460            Ok(expr) => {
461                let site = Site {
462                    at,
463                    records,
464                    step,
465                    field: None,
466                };
467                self.hook.expr(site, expr.raw(), &mut self.issues);
468                Some(expr)
469            }
470            Err(error) => {
471                let issue = Issue::new(IssueCode::TemplateCompile, at.clone(), error.message());
472                self.report(issue, step);
473                None
474            }
475        }
476    }
477
478    /// A condition at `at`: compiled and visited as not recorded. Its row in
479    /// `conds`, or `None` when it did not compile or folds to the literal
480    /// `true`, which keeps no row and lowers to no `Cond`, so a trivially
481    /// true condition costs nothing at run time.
482    fn cond(&mut self, raw: &Logic, at: AuthoredPath, step: Stamp<'_>) -> Option<u32> {
483        let expr = self.expr(raw, &at, false, step)?;
484        if expr.is_trivially_true() {
485            return None;
486        }
487        let row = next(self.conds.len());
488        self.conds.push(CondSite {
489            expr,
490            step: step.map(|(_, id)| id.to_owned()),
491        });
492        Some(row)
493    }
494
495    /// `dotted` as a context path, else `INVALID_PATH` at `at`. Parse has
496    /// already checked every binding, and a definition cannot change after
497    /// it; this reports, rather than panics, if the two rules ever drift.
498    fn path(&mut self, dotted: &str, at: AuthoredPath, step: Stamp<'_>) -> Option<Path> {
499        match Path::from_str(dotted) {
500            Ok(path) => Some(path),
501            Err(error) => {
502                let issue = Issue::new(IssueCode::InvalidPath, at, error.message());
503                self.report(issue, step);
504                None
505            }
506        }
507    }
508
509    /// The loop's `over`, recorded, then its slots, each
510    /// `temp_data.<slot>`. Returns its row in `loops`.
511    ///
512    /// The bounds are `parse`'s: a definition cannot be edited after it, so
513    /// the interpreter's termination (`increment >= 1`) holds by
514    /// construction.
515    fn r#loop(&mut self, config: &LoopDef) -> u32 {
516        let at = AuthoredPath::root().join("loop");
517        let over = config
518            .over
519            .as_ref()
520            .and_then(|raw| self.expr(raw, &at.join("over"), true, None));
521        let mut slot = |slot: &Option<String>, key: &str| {
522            slot.as_ref()
523                .and_then(|slot| self.path(&format!("temp_data.{slot}"), at.join(key), None))
524        };
525        let counter = slot(&config.counter, "counter");
526        let item = slot(&config.r#as, "as");
527        let scratch = slot(&config.scratch, "scratch");
528        let row = next(self.loops.len());
529        self.loops.push(LoopSite {
530            init: config.init,
531            increment: config.increment,
532            max: config.max,
533            counter,
534            over,
535            r#as: item,
536            scratch,
537        });
538        row
539    }
540
541    /// The steps of the list at `at`, pre-order: each group before its
542    /// members, every one a setup step when `setup`. This is
543    /// [`WorkflowDef::walk`]'s order.
544    fn steps(&mut self, steps: &[StepDef], at: &AuthoredPath, setup: bool) {
545        for (i, step) in steps.iter().enumerate() {
546            let at = at.join(i);
547            let kind = step.kind(setup);
548            match step {
549                StepDef::Group(group) => self.group(&at, kind, group, setup),
550                StepDef::Task(task) => self.task(&at, kind, task),
551            }
552        }
553    }
554
555    /// A group: its condition, its members, then a `Halt` when it is
556    /// terminal. A false condition jumps past the `Halt`.
557    fn group(&mut self, at: &AuthoredPath, kind: StepKind, group: &GroupDef, setup: bool) {
558        let cond = group
559            .condition
560            .as_ref()
561            .and_then(|raw| self.cond(raw, at.join("condition"), Some((kind, &group.id))));
562        let open = self.lower.open(cond, CondKind::Group);
563        self.steps(&group.tasks, &at.join("tasks"), setup);
564        if group.terminal {
565            self.lower.halt();
566        }
567        self.lower.close(open);
568    }
569
570    /// A task: its condition, its fan-out, then its call, lowered to its
571    /// `Cond` and its `Call` or `FanOut`.
572    fn task(&mut self, at: &AuthoredPath, kind: StepKind, task: &TaskDef) {
573        let step = (kind, task.id.as_str());
574        let cond = task
575            .condition
576            .as_ref()
577            .and_then(|raw| self.cond(raw, at.join("condition"), Some(step)));
578        let fan = task.for_each.as_ref().and_then(|config| {
579            let fan = self.fan(config, &at.join("for_each"), step)?;
580            let row = next(self.fans.len());
581            self.fans.push(fan);
582            Some(row)
583        });
584        let row = next(self.tasks.len());
585        self.tasks.push(TaskMeta {
586            id: task.id.clone(),
587            function: task.function.name.clone(),
588            kind,
589            path: at.clone(),
590        });
591        let site = self.callee(task, at, step).map(|callee| {
592            let site = next(self.calls.len());
593            self.calls.push(CallSite {
594                task: row,
595                callee,
596                policy: Policy {
597                    on_failure: task.on_failure,
598                    on_rejection: task.on_rejection,
599                    terminal: task.terminal,
600                },
601            });
602            site
603        });
604        let open = self.lower.open(cond, CondKind::Task);
605        match (site, task.for_each.is_some(), fan) {
606            (Some(site), false, _) => self.lower.call(site),
607            (Some(site), true, Some(fan)) => self.lower.fan_out(site, fan),
608            // A Rejected issue is recorded, so the workflow is never lowered
609            // for use: the task emits nothing.
610            _ => {}
611        }
612        self.lower.close(open);
613    }
614
615    /// A fan-out at `at`: its `over`, recorded, then its bindings as
616    /// paths, `into` with room for the index each element's result lands
617    /// at.
618    ///
619    /// The fan-out's rules are `parse`'s: a definition cannot be edited
620    /// after it, so no call starts under `max_concurrency: 0` and no `into`
621    /// is a root.
622    fn fan(
623        &mut self,
624        config: &ForEachDef,
625        at: &AuthoredPath,
626        step: (StepKind, &str),
627    ) -> Option<FanSite> {
628        let over = self.expr(&config.over, &at.join("over"), true, Some(step));
629        let item = format!("temp_data.{}", config.r#as);
630        let bound = self
631            .path(&item, at.join("as"), Some(step))
632            .and_then(|item_path| {
633                let index = self.path(&format!("{item}_index"), at.join("as"), Some(step))?;
634                Some((item_path, index))
635            });
636        let mut context = |path: &Option<String>, key: &str| {
637            path.as_ref()
638                .and_then(|path| self.path(path, at.join(key), Some(step)))
639        };
640        let collect = context(&config.collect, "collect");
641        let into = context(&config.into, "into").and_then(|into| {
642            if into.segs().len() < MAX_PATH_SEGMENTS {
643                return Some(into);
644            }
645            let message = format!(
646                "path `{into}` has {MAX_PATH_SEGMENTS} segments, and each element's result \
647                 lands one segment below it; a path holds at most {MAX_PATH_SEGMENTS}"
648            );
649            let issue = Issue::new(IssueCode::InvalidPath, at.join("into"), message);
650            self.report(issue, Some(step));
651            None
652        });
653        let (over, (item, index)) = over.zip(bound)?;
654        Some(FanSite {
655            over,
656            r#as: item,
657            index,
658            max_concurrency: config.max_concurrency,
659            collect,
660            into,
661        })
662    }
663
664    /// The task's function: resolved, its config compiled at
665    /// `<at>.function.input` and visited ([`compile_callee`]), and its
666    /// references checked. `None` when the name is unknown, which is
667    /// `UNKNOWN_FUNCTION` naming the nearest catalogue name if one is close,
668    /// or the config was Rejected.
669    fn callee(
670        &mut self,
671        task: &TaskDef,
672        at: &AuthoredPath,
673        step: (StepKind, &str),
674    ) -> Option<Callee> {
675        let registry = self.registry;
676        let name = &task.function.name;
677        let Some(entry) = registry.entry(name) else {
678            let names: Vec<&str> = registry.catalogue().map(|f| f.name).collect();
679            let message = match near_miss(name, &names) {
680                Some(near) => format!("no function `{name}` is registered; did you mean `{near}`?"),
681                None => format!("no function `{name}` is registered"),
682            };
683            let issue = Issue::new(
684                IssueCode::UnknownFunction,
685                at.join("function").join("name"),
686                message,
687            );
688            self.report(issue, Some(step));
689            return None;
690        };
691        let input_at = at.join("function").join("input");
692        let input = &task.function.input;
693        let site = CalleeAt {
694            name,
695            workflow_id: self.workflow_id,
696            step,
697            input_at: &input_at,
698        };
699        let callee = compile_callee(
700            registry.exprs(),
701            entry,
702            input,
703            &site,
704            &mut *self.hook,
705            &mut self.issues,
706        )?;
707        if let Entry::Host(f) = entry {
708            self.references(f.spec(), input, &input_at, step);
709        }
710        Some(callee)
711    }
712
713    /// The oracle, over each `Reference` in `spec`: a literal string at the
714    /// field below `input` that it does not resolve is
715    /// `UNRESOLVED_REFERENCE` at the field. A field that is not an authored
716    /// path names nothing, and is not checked.
717    fn references(
718        &mut self,
719        spec: &FunctionSpec,
720        input: &Value,
721        input_at: &AuthoredPath,
722        step: (StepKind, &str),
723    ) {
724        let Some(oracle) = self.resources else {
725            return;
726        };
727        let mut cx = CompileCx::new(self.registry.exprs(), &mut self.issues)
728            .at_path(input_at.clone())
729            .for_step(step.0, step.1);
730        for reference in &spec.references {
731            let Ok(field) = AuthoredPath::from_str(&reference.field) else {
732                continue;
733            };
734            if let Some(Value::String(name)) = lookup(input, &field)
735                && let Err(why) = oracle.resolve(&reference.kind, name)
736            {
737                let kind = &reference.kind;
738                let message = if why.is_empty() {
739                    format!("no {kind} named `{name}` resolves")
740                } else {
741                    format!("`{name}` does not resolve as a {kind}: {why}")
742                };
743                cx.adopt(Issue::new(IssueCode::UnresolvedReference, field, message));
744            }
745        }
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752    use crate::base::Result;
753    use crate::def::{FailurePolicy, RejectionPolicy, parse};
754    use crate::function::builtins::{Builtin, BuiltinKind};
755    use crate::function::{Ctx, Function, FunctionConfig, Outcome};
756    use serde_json::json;
757
758    #[derive(FunctionConfig)]
759    struct EchoConfig {
760        #[config(records)]
761        text: Expr<String>,
762        label: Option<Expr<String>>,
763    }
764
765    struct Echo;
766
767    impl Function for Echo {
768        type Config = EchoConfig;
769        async fn call(&self, _ctx: &mut Ctx<'_>, _cfg: &EchoConfig) -> Result<Outcome> {
770            Ok(Outcome::Ok)
771        }
772    }
773
774    /// A step stamp, owned.
775    type Step = Option<(StepKind, String)>;
776
777    /// Every site the hook was called at: its path, `records`, and stamp.
778    #[derive(Default)]
779    struct Recorder(Vec<(String, bool, Step)>);
780
781    impl Hook for Recorder {
782        fn expr(&mut self, site: Site<'_>, _raw: &Value, _issues: &mut Vec<Issue>) {
783            let step = site.step.map(|(kind, id)| (kind, id.to_owned()));
784            self.0.push((site.at.to_string(), site.records, step));
785        }
786    }
787
788    fn compiler() -> Compiler {
789        let mut registry = Registry::default();
790        registry.register("echo", Echo);
791        registry.compiler(CompileOptions::default())
792    }
793
794    /// A workflow with an expression at every kind of site, and every
795    /// workflow fact set.
796    fn every_site() -> WorkflowDef {
797        let json = json!({
798            "id": "w", "channel": "orders", "priority": 4, "status": "paused", "version": 3,
799            "on_failure": "continue", "rollout": {"bucket_start": 10, "bucket_end": 60},
800            "condition": {"var": "metadata.go"},
801            "loop": {
802                "counter": "i", "init": 1, "increment": 2, "max": 9,
803                "over": {"var": "data.batches"}, "as": "batch", "scratch": "acc",
804                "setup": [{"id": "s", "condition": {"var": "data.s"},
805                           "function": {"name": "log", "input": {"message": "start"}}}],
806            },
807            "tasks": [
808                {"id": "g", "condition": {"var": "data.g"}, "tasks": [
809                    {"id": "f", "condition": {"var": "data.f"},
810                     "for_each": {"over": {"var": "data.items"}, "as": "item",
811                                  "max_concurrency": 2, "collect": "temp_data.price",
812                                  "into": "temp_data.acc.prices"},
813                     "on_failure": "continue", "on_rejection": "halt",
814                     "function": {"name": "echo", "input": {
815                         "text": {"var": "temp_data.item"}, "label": "x"}}},
816                ]},
817                {"id": "m", "terminal": true, "function": {"name": "map", "input": {
818                    "mappings": [{"path": "data.x", "logic": {"var": "data.y"}}]}}},
819            ],
820        });
821        let parsed = parse(&json, ParseOptions::default());
822        parsed
823            .def
824            .unwrap_or_else(|| panic!("the definition parses: {:?}", parsed.issues))
825    }
826
827    fn task(id: &str) -> Step {
828        Some((StepKind::Task, id.to_owned()))
829    }
830
831    #[test]
832    fn the_hook_sees_every_site_at_its_absolute_path() {
833        let mut recorder = Recorder::default();
834        let (_, issues) = compiler().compile_with(&every_site(), &mut recorder);
835        assert!(issues.is_empty(), "{issues:?}");
836        let site = |at: &str, records: bool, step: Step| (at.to_owned(), records, step);
837        let setup = Some((StepKind::Setup, "s".to_owned()));
838        let group = Some((StepKind::Group, "g".to_owned()));
839        assert_eq!(
840            recorder.0,
841            [
842                site("condition", false, None),
843                site("loop.over", true, None),
844                site("loop.setup[0].condition", false, setup.clone()),
845                site("loop.setup[0].function.input.message", true, setup),
846                site("tasks[0].condition", false, group),
847                site("tasks[0].tasks[0].condition", false, task("f")),
848                site("tasks[0].tasks[0].for_each.over", true, task("f")),
849                site("tasks[0].tasks[0].function.input.text", true, task("f")),
850                site("tasks[0].tasks[0].function.input.label", false, task("f")),
851                site("tasks[1].function.input.mappings[0].path", true, task("m")),
852                site("tasks[1].function.input.mappings[0].logic", true, task("m")),
853            ]
854        );
855    }
856
857    #[test]
858    fn the_compile_walk_visits_the_steps_in_def_walk_order() {
859        // `UNGUARDED_VALIDATION` reads its chain from `WorkflowDef::walk`, and
860        // everything else from the compile walk: the two orders are one.
861        // Every step has a condition, so the hook sees each step once.
862        let c = |id: &str| json!({"var": format!("data.{id}")});
863        let t = |id: &str| {
864            json!({"id": id, "condition": c(id),
865                   "function": {"name": "log", "input": {"message": "m"}}})
866        };
867        let json = json!({"id": "w", "loop": {"max": 2, "setup": [
868                t("s1"),
869                {"id": "sg", "condition": c("sg"), "tasks": [t("s2"), t("s3")]},
870            ]},
871            "tasks": [
872                {"id": "a", "condition": c("a"), "tasks": [
873                    {"id": "b", "condition": c("b"), "tasks": [t("t1")]},
874                    t("t2"),
875                    {"id": "c", "condition": c("c"), "tasks": [
876                        {"id": "d", "condition": c("d"), "tasks": [t("t3")]},
877                    ]},
878                ]},
879                t("t4"),
880            ],
881        });
882        let def = parse(&json, ParseOptions::default()).def.unwrap();
883        let mut recorder = Recorder::default();
884        let (_, issues) = compiler().compile_with(&def, &mut recorder);
885        assert!(issues.is_empty(), "{issues:?}");
886        let walked: Vec<(String, Step)> = recorder
887            .0
888            .into_iter()
889            .filter_map(|(at, _, step)| Some((at.strip_suffix(".condition")?.to_owned(), step)))
890            .collect();
891        let expected: Vec<(String, Step)> = def
892            .walk()
893            .map(|(path, kind, step)| (path.to_string(), Some((kind, step.id().to_owned()))))
894            .collect();
895        assert_eq!(walked.len(), 12);
896        assert_eq!(walked, expected);
897    }
898
899    #[test]
900    fn check_steps_reaches_the_same_sites_under_its_root() {
901        // `check_steps` compiles through the same per-step walk.
902        let steps = json!([{"id": "f", "condition": {"var": "data.f"},
903            "function": {"name": "echo", "input": {"text": "a", "label": {"var": "data.l"}}}}]);
904        let root: AuthoredPath = "tasks[2].tasks".parse().unwrap();
905        let (parsed, issues) = def::parse_steps(&steps, &root, ParseOptions::default());
906        assert!(issues.is_empty(), "{issues:?}");
907        let parsed = parsed.unwrap();
908        let compiler = compiler();
909        let mut recorder = Recorder::default();
910        let mut walk = Walk::new(&compiler, "", &mut recorder);
911        walk.steps(&parsed, &root, false);
912        assert!(walk.issues.is_empty(), "{:?}", walk.issues);
913        let paths: Vec<_> = recorder.0.iter().map(|(at, ..)| at.as_str()).collect();
914        assert_eq!(
915            paths,
916            [
917                "tasks[2].tasks[0].condition",
918                "tasks[2].tasks[0].function.input.text",
919                "tasks[2].tasks[0].function.input.label",
920            ]
921        );
922    }
923
924    #[test]
925    fn the_side_tables_are_filled_in_walk_order() {
926        let compiled = compiler().compile_one(&every_site()).unwrap();
927        assert!(compiled.issues.is_empty(), "{:?}", compiled.issues);
928        let w = &compiled.workflow;
929
930        // The workflow's facts.
931        assert_eq!(w.id(), "w");
932        assert_eq!(w.channel, "orders");
933        assert_eq!((w.priority, w.version), (4, 3));
934        assert_eq!(w.status, Status::Paused);
935        assert_eq!(w.rollout, Some(Rollout::new(10, 60)));
936        assert_eq!(w.on_failure, WorkflowFailurePolicy::Continue);
937
938        // The lowering: a setup step, a loop over a group holding a
939        // conditional fan-out, and a terminal task.
940        let listing = [
941            " 0 Cond workflow else->end",
942            " 1 Cond task s else->3",
943            " 2 Call s log builtin fail->end",
944            " 3 LoopInit exit->end",
945            " 4 Cond workflow else->end",
946            " 5 Cond group g else->8",
947            " 6 Cond task f else->8",
948            " 7 FanOut f echo host as=item fail->9",
949            " 8 Call m map builtin fail->9 terminal",
950            " 9 LoopNext body->4 exit->end",
951        ];
952        let expected: String = listing.iter().map(|line| format!("{line}\n")).collect();
953        assert_eq!(w.listing(), expected, "\n{}", w.listing());
954        assert_eq!(w.check_invariants(), Ok(()));
955
956        // One row per task, never per group: setup first.
957        let rows: Vec<_> = w
958            .tasks
959            .iter()
960            .map(|t| (&*t.id, &*t.function, t.kind, t.path.to_string()))
961            .collect();
962        assert_eq!(
963            rows,
964            [
965                ("s", "log", StepKind::Setup, "loop.setup[0]".to_owned()),
966                ("f", "echo", StepKind::Task, "tasks[0].tasks[0]".to_owned()),
967                ("m", "map", StepKind::Task, "tasks[1]".to_owned()),
968            ]
969        );
970
971        // Each call site keeps its task row and the task's policies.
972        let calls: Vec<_> = w
973            .calls
974            .iter()
975            .map(|c| {
976                let callee = match &c.callee {
977                    Callee::Builtin(b) => Some(b.kind()),
978                    Callee::Host(_) => None,
979                };
980                (
981                    c.task,
982                    callee,
983                    c.policy.on_failure,
984                    c.policy.on_rejection,
985                    c.policy.terminal,
986                )
987            })
988            .collect();
989        assert_eq!(
990            calls,
991            [
992                (
993                    0,
994                    Some(BuiltinKind::Log),
995                    FailurePolicy::Fail,
996                    RejectionPolicy::Continue,
997                    false
998                ),
999                (
1000                    1,
1001                    None,
1002                    FailurePolicy::Continue,
1003                    RejectionPolicy::Halt,
1004                    false
1005                ),
1006                (
1007                    2,
1008                    Some(BuiltinKind::Map),
1009                    FailurePolicy::Fail,
1010                    RejectionPolicy::Continue,
1011                    true
1012                ),
1013            ]
1014        );
1015        assert!(matches!(
1016            &w.calls[2].callee,
1017            Callee::Builtin(Builtin::Map(_))
1018        ));
1019
1020        // Every condition, with its step and authored expression.
1021        let conds: Vec<_> = w
1022            .conds
1023            .iter()
1024            .map(|c| (c.step.as_deref(), c.expr.raw().clone()))
1025            .collect();
1026        assert_eq!(
1027            conds,
1028            [
1029                (None, json!({"var": "metadata.go"})),
1030                (Some("s"), json!({"var": "data.s"})),
1031                (Some("g"), json!({"var": "data.g"})),
1032                (Some("f"), json!({"var": "data.f"})),
1033            ]
1034        );
1035
1036        // The fan-out's bindings are `temp_data` paths; collect and into are
1037        // context paths.
1038        let [fan] = &w.fans[..] else {
1039            panic!("one fan-out");
1040        };
1041        assert_eq!(fan.over.raw(), &json!({"var": "data.items"}));
1042        assert_eq!(fan.r#as.to_string(), "temp_data.item");
1043        assert_eq!(fan.index.to_string(), "temp_data.item_index");
1044        assert_eq!(fan.max_concurrency, 2);
1045        assert_eq!(fan.collect.as_ref().unwrap().to_string(), "temp_data.price");
1046        assert_eq!(
1047            fan.into.as_ref().unwrap().to_string(),
1048            "temp_data.acc.prices"
1049        );
1050
1051        // The loop's slots are `temp_data` paths.
1052        let [lp] = &w.loops[..] else {
1053            panic!("one loop");
1054        };
1055        assert_eq!((lp.init, lp.increment, lp.max), (1, 2, 9));
1056        assert_eq!(lp.counter.as_ref().unwrap().to_string(), "temp_data.i");
1057        assert_eq!(
1058            lp.over.as_ref().unwrap().raw(),
1059            &json!({"var": "data.batches"})
1060        );
1061        assert_eq!(lp.r#as.as_ref().unwrap().to_string(), "temp_data.batch");
1062        assert_eq!(lp.scratch.as_ref().unwrap().to_string(), "temp_data.acc");
1063    }
1064
1065    #[test]
1066    fn a_workflow_without_a_loop_or_slots_has_none() {
1067        let json = json!({"id": "w", "loop": {"max": 2},
1068            "tasks": [{"id": "t", "function": {"name": "echo", "input": {"text": "a"}}}]});
1069        let def = parse(&json, ParseOptions::default()).def.unwrap();
1070        let compiled = compiler().compile_one(&def).unwrap();
1071        let [lp] = &compiled.workflow.loops[..] else {
1072            panic!("one loop");
1073        };
1074        assert!(lp.counter.is_none() && lp.over.is_none());
1075        assert!(lp.r#as.is_none() && lp.scratch.is_none());
1076        assert!(compiled.workflow.conds.is_empty());
1077        assert!(compiled.workflow.fans.is_empty());
1078    }
1079
1080    /// An `echo` task.
1081    fn echo_task() -> Value {
1082        json!({"id": "t", "function": {"name": "echo", "input": {"text": "a"}}})
1083    }
1084
1085    #[test]
1086    fn compile_refuses_an_into_with_no_room_for_an_index() {
1087        // Each element's result lands at `into.<index>`, one segment below
1088        // `into`, and a path holds at most 64.
1089        for (depth, refused) in [(64, true), (63, false)] {
1090            let into = format!("data{}", ".k".repeat(depth - 1));
1091            let mut task = echo_task();
1092            task["for_each"] = json!({"over": {"var": "data.items"}, "as": "item",
1093                "collect": "temp_data.r", "into": into});
1094            let def = parse(
1095                &json!({"id": "w", "tasks": [task]}),
1096                ParseOptions::default(),
1097            )
1098            .def
1099            .unwrap();
1100            let issues = compiler().compile_one(&def).err().unwrap_or_default();
1101            let issues: Vec<_> = issues
1102                .into_iter()
1103                .map(|i| (i.code, i.path.to_string(), i.message))
1104                .collect();
1105            if refused {
1106                assert_eq!(
1107                    issues,
1108                    [(
1109                        IssueCode::InvalidPath,
1110                        "tasks[0].for_each.into".to_owned(),
1111                        format!(
1112                            "path `{into}` has 64 segments, and each element's result lands \
1113                             one segment below it; a path holds at most 64"
1114                        ),
1115                    )]
1116                );
1117            } else {
1118                assert!(issues.is_empty(), "{issues:?}");
1119            }
1120        }
1121    }
1122
1123    #[test]
1124    fn debug_shows_the_id_the_op_count_and_the_table_sizes() {
1125        let compiled = compiler().compile_one(&every_site()).unwrap();
1126        assert_eq!(
1127            format!("{:?}", compiled.workflow),
1128            "CompiledWorkflow { id: \"w\", ops: 10, conds: 4, calls: 3, fans: 1, loops: 1, \
1129             tasks: 3, .. }"
1130        );
1131    }
1132
1133    #[test]
1134    fn debug_shows_the_registry_and_whether_an_oracle_is_set() {
1135        assert_eq!(
1136            format!("{:?}", CompileOptions::default()),
1137            "CompileOptions { resources: false, .. }"
1138        );
1139        let compiler = format!("{:?}", compiler());
1140        assert!(
1141            compiler.starts_with("Compiler { registry: Registry { functions: [\"map\""),
1142            "{compiler}"
1143        );
1144        assert!(compiler.contains("\"echo\"]"), "{compiler}");
1145    }
1146
1147    #[test]
1148    fn a_compiled_workflow_crosses_threads() {
1149        fn send_sync<T: Send + Sync>() {}
1150        send_sync::<CompiledWorkflow>();
1151        send_sync::<Compiler>();
1152    }
1153}