1use 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
25pub trait ResourceOracle: Send + Sync + 'static {
51 fn resolve(&self, kind: &str, name: &str) -> std::result::Result<(), String>;
62}
63
64#[derive(Clone, Default)]
66#[non_exhaustive]
67pub struct CompileOptions {
68 pub resources: Option<Arc<dyn ResourceOracle>>,
71}
72
73impl fmt::Debug for CompileOptions {
74 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 #[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 pub fn compiler(&self, options: CompileOptions) -> Compiler {
94 Compiler {
95 registry: self.clone(),
96 options,
97 }
98 }
99}
100
101#[derive(Clone, Debug)]
124pub struct Compiler {
125 pub(super) registry: Registry,
126 pub(super) options: CompileOptions,
127}
128
129impl Compiler {
130 pub fn registry(&self) -> &Registry {
132 &self.registry
133 }
134
135 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 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 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 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 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 let cond = def
220 .condition
221 .as_ref()
222 .and_then(|raw| walk.cond(raw, root.join("condition"), None));
223 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 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 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#[derive(Debug)]
270#[non_exhaustive]
271pub struct Compiled {
272 pub workflow: CompiledWorkflow,
274 pub issues: Vec<Issue>,
276}
277
278pub struct CompiledWorkflow {
280 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 pub fn id(&self) -> &str {
302 &self.id
303 }
304
305 pub fn fingerprint(&self) -> Fingerprint {
308 self.fingerprint
309 }
310}
311
312impl fmt::Debug for CompiledWorkflow {
313 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
327pub(crate) struct CalleeAt<'a> {
330 pub(crate) name: &'a str,
332 pub(crate) workflow_id: &'a str,
334 pub(crate) step: (StepKind, &'a str),
336 pub(crate) input_at: &'a AuthoredPath,
338}
339
340pub(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 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
386pub(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
395pub(super) fn next(len: usize) -> u32 {
397 u32::try_from(len).expect("a workflow holds fewer than 2^32 steps")
398}
399
400fn 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
408struct Walk<'w> {
411 registry: &'w Registry,
412 resources: Option<&'w dyn ResourceOracle>,
413 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 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 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 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 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 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 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 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 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 _ => {}
611 }
612 self.lower.close(open);
613 }
614
615 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 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 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 type Step = Option<(StepKind, String)>;
776
777 #[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 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 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 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 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 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 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 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 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 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 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 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 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}