Skip to main content

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}