nmbrs_runtime/wrappers/dryrun.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dryrun short-circuit wrapper.
5//!
6//! Installed as the outermost wrapper when the runner is in
7//! `dryrun=` mode. At execute it returns an empty `OpResult`
8//! without calling its inner — wraps the op and does NOT call it,
9//! per the design contract. Sits outside every other wrapper so
10//! verify / metrics / poll / etc. never observe the empty result
11//! and can't fire spurious diagnostics.
12//!
13//! Under `dryrun=cycle` the real adapter still constructs in
14//! full — connecting, preparing statements, gathering metadata —
15//! because `dryrun=cycle` means "make the cycle path fully
16//! executable, then suppress only the outbound `execute()`." The
17//! wrapper handles that suppression at cycle time; no adapter
18//! substitution is needed.
19
20use std::sync::Arc;
21
22use crate::adapter::WrappingDispenser;
23use crate::adapter::{ExecutionError, OpDispenser, OpResult};
24use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
25
26/// SRD-32a wrapper name.
27pub const NAME: WrapperName = WrapperName::new("dryrun");
28
29/// Trigger: op carries the injected `dryrun:` parameter (a
30/// session-originated marker, NOT something workload authors
31/// write by hand — see `inject_dryrun_intent`).
32fn triggers(s: WrapperSubject) -> bool {
33 let Some(template) = s.op() else {
34 return false;
35 };
36 template.params.contains_key("dryrun")
37}
38
39/// Reports the mode (`emit` / `silent` / `json`) from the
40/// injected `dryrun:` parameter.
41fn describe_assignment(s: WrapperSubject) -> Option<String> {
42 let template = s.op()?;
43 let v = template.params.get("dryrun")?;
44 let mode = v.as_str().unwrap_or("silent");
45 Some(format!("dryrun: short-circuit (mode={mode})"))
46}
47
48/// DRYRUN must be the absolute outermost wrapper so its
49/// short-circuit happens BEFORE any inner wrapper (verify /
50/// metrics / poll / etc.) observes the empty body.
51const FORBIDS_OUTER: &[WrapperName] = &[
52 super::traverse::NAME,
53 super::delay::NAME,
54 crate::validation::WRAPPER_NAME,
55 super::poll::NAME,
56 super::r#if::NAME,
57 // `fields` is INTENTIONALLY allowed outer of dryrun — under
58 // `dryrun=fields` the fields wrapper's pre-execute render is
59 // the surface that produces operator-visible output, so it
60 // must run before DRYRUN's short-circuit.
61 super::result::NAME,
62 super::metrics::NAME,
63 super::memo::NAME,
64 super::gutter::NAME,
65 // `while` loops the entire wrapper stack inner of it; dryrun
66 // must short-circuit BEFORE the loop or the dry-run pass
67 // would burn cycles iterating a no-op stand-in.
68 super::r#while::NAME,
69 // `rate` introduces a per-iteration wait; dryrun must
70 // short-circuit BEFORE the wait or the dry-run pass would
71 // sit on the rate-limiter for nothing.
72 super::rate::NAME,
73];
74
75inventory::submit! {
76 WrapperRegistration {
77 name: NAME,
78 // `owned_fields = ["dryrun"]` participates in the parse-
79 // time misplaced-field guard the same way every other
80 // wrapper's owned fields do, so a workload that
81 // erroneously declares `dryrun:` on an op surfaces an
82 // init-time error pointing here.
83 owned_fields: &["dryrun"],
84 triggers,
85 requires_inner: &[],
86 forbids_outer: FORBIDS_OUTER,
87 mutually_exclusive_with: &[],
88 describe_assignment,
89 levels: &[crate::wrapper_registry::WrapperLevel::Op],
90 }
91}
92
93/// Wraps an inner [`OpDispenser`] and short-circuits per cycle
94/// — does nothing more than wrap the op and not call it.
95///
96/// Per-cycle contract: `execute` returns `Ok(OpResult { body:
97/// None, skipped: true })` without touching `ctx.wires`, the
98/// inner dispenser, or any field templates. Whatever per-cycle
99/// preparation work the real adapter would do happens on demand
100/// when its dispenser is called — under dryrun the dispenser
101/// isn't called, so no preparation work fires.
102///
103/// **Not the place for emit/json display.** The historical
104/// emit/json modes that printed resolved fields per cycle were
105/// extra work this wrapper had no business doing — those belong
106/// in a separate display wrapper or the adapter itself. The
107/// dryrun wrapper's only job is to suppress the outbound call.
108pub struct DryRunWrapper {
109 inner: Arc<dyn OpDispenser>,
110}
111
112impl DryRunWrapper {
113 pub fn wrap(inner: Arc<dyn OpDispenser>) -> Arc<dyn OpDispenser> {
114 Arc::new(Self { inner })
115 }
116}
117
118impl WrappingDispenser for DryRunWrapper {}
119
120impl OpDispenser for DryRunWrapper {
121 fn execute<'a>(
122 &'a self,
123 _cycle: u64,
124 _ctx: &'a crate::fixture::ExecCtx<'a>,
125 ) -> std::pin::Pin<
126 Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
127 > {
128 Box::pin(async move {
129 // Pure short-circuit. No field resolution, no wires
130 // access, no inner call. `skipped: true` so any
131 // outer wrapper that observes the result respects
132 // the short-circuit per the existing
133 // `if result.skipped { return Ok(result); }` pattern.
134 Ok(OpResult {
135 body: None,
136 skipped: true,
137 })
138 })
139 }
140
141 fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
142 Some(self.inner.as_ref())
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::adapter::{ExecutionError, OpResult};
150 use crate::fixture::{ExecCtx, ResolvedPulls};
151
152 /// An inner dispenser that fails the test if its `execute` is
153 /// ever called. Use as `DryRunWrapper`'s inner to pin the
154 /// short-circuit invariant.
155 struct PanicIfCalled;
156 impl OpDispenser for PanicIfCalled {
157 fn execute<'a>(
158 &'a self,
159 _cycle: u64,
160 _ctx: &'a ExecCtx<'a>,
161 ) -> std::pin::Pin<
162 Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
163 > {
164 Box::pin(async {
165 panic!(
166 "DryRunWrapper invariant violated: inner dispenser was called \
167 in dryrun mode. The wrapper must short-circuit BEFORE any \
168 wrapped layer (verify, metrics, poll, …) observes the result."
169 );
170 })
171 }
172 }
173
174 #[tokio::test]
175 async fn dry_run_wrapper_short_circuits_inner() {
176 let inner: Arc<dyn OpDispenser> = Arc::new(PanicIfCalled);
177 let wrapper = DryRunWrapper::wrap(inner);
178
179 let mut kernel =
180 polydat::dsl::compile::compile_polydat_interpreter("input cycle: u64\n").unwrap();
181 let cw = crate::wires::CycleWires::new(&mut kernel);
182 let fields = crate::adapter::ResolvedFields::new(vec![], vec![]);
183 let pulls = ResolvedPulls::empty();
184 let ctx = ExecCtx::with_wires(&fields, &pulls, &cw);
185
186 let result = wrapper
187 .execute(0, &ctx)
188 .await
189 .expect("dryrun should succeed");
190 assert!(result.body.is_none(), "dryrun result carries no body");
191 assert!(
192 result.skipped,
193 "dryrun result is marked skipped so any wrapper that DID sit \
194 outside us (defensive) honours the existing skip-on-skipped \
195 contract"
196 );
197 }
198}