Skip to main content

nmbrs_runtime/wrappers/
memo.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Memo wrapper — publishes a short human-visible string to the
5//! activity's `memo` ArcSwap before / after the inner op runs.
6
7use std::sync::Arc;
8
9use crate::adapter::WrappingDispenser;
10use crate::adapter::{ExecutionError, OpDispenser, OpResult};
11use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
12
13/// SRD-32a wrapper name.
14pub const NAME: WrapperName = WrapperName::new("memo");
15
16/// Trigger: `memo:` is either a bare string (shorthand) or a
17/// map with `before:` / `after:` keys.
18fn triggers(s: WrapperSubject) -> bool {
19    let Some(template) = s.op() else {
20        return false;
21    };
22    template
23        .params
24        .get("memo")
25        .map(|v| v.is_string() || v.is_object())
26        .unwrap_or(false)
27}
28
29fn describe_assignment(s: WrapperSubject) -> Option<String> {
30    let template = s.op()?;
31    let v = template.params.get("memo")?;
32    if let Some(s) = v.as_str() {
33        if s.is_empty() {
34            return None;
35        }
36        Some(format!("memo: \"{s}\" (before+after)"))
37    } else if let Some(obj) = v.as_object() {
38        let before = obj.get("before").and_then(|x| x.as_str());
39        let after = obj.get("after").and_then(|x| x.as_str());
40        match (before, after) {
41            (Some(b), Some(a)) => Some(format!("memo: before \"{b}\" / after \"{a}\"")),
42            (Some(b), None) => Some(format!("memo: before \"{b}\"")),
43            (None, Some(a)) => Some(format!("memo: after \"{a}\"")),
44            (None, None) => None,
45        }
46    } else {
47        None
48    }
49}
50
51inventory::submit! {
52    WrapperRegistration {
53        name: NAME,
54        // `memo:` is the sole discriminant — string shorthand
55        // or `{before, after}` map. No inner/outer constraints:
56        // memo publication is independent of every other
57        // wrapper's behaviour; it sees the same wires every
58        // wrapper sees and writes to its own atomic.
59        owned_fields: &["memo"],
60        triggers,
61        requires_inner: &[super::traverse::NAME],
62        forbids_outer: &[],
63        mutually_exclusive_with: &[],
64        describe_assignment,
65        levels: &[crate::wrapper_registry::WrapperLevel::Op],
66    }
67}
68
69/// Op-wrapper whose only side effect is to publish a short
70/// human-visible string to the activity's `memo` ArcSwap.
71///
72/// Two templates are accepted:
73///
74/// - `before`: rendered + stored *before* the inner op runs.
75///   Useful for "now compacting {table}" style state — reads
76///   workload params / cycle wires that exist pre-execution.
77/// - `after`: rendered + stored *after* the inner op returns Ok.
78///   Lets the next-rendered memo reflect the post-op state.
79///
80/// Either or both may be present. A shorthand string form
81/// (`memo: "doing X"`) is parsed as both-templates-the-same.
82///
83/// The wrapper is a no-op on inner errors — the result is
84/// returned unchanged whether or not memo publication happened.
85/// Substitution failures are downgraded to a debug log; we
86/// don't fail an otherwise-good op because the memo couldn't
87/// render.
88pub struct MemoDispenser {
89    inner: Arc<dyn OpDispenser>,
90    before_template: Option<String>,
91    after_template: Option<String>,
92    /// Shared atomic owned by the activity (see
93    /// `Activity::memo`). Cloned into the wrapper at wrap-time
94    /// so writes here are visible to the inline-status thread
95    /// and end-of-phase readout context without a separate
96    /// channel.
97    memo_state: Arc<arc_swap::ArcSwap<String>>,
98}
99
100impl MemoDispenser {
101    pub fn wrap(
102        inner: Arc<dyn OpDispenser>,
103        before_template: Option<String>,
104        after_template: Option<String>,
105        memo_state: Arc<arc_swap::ArcSwap<String>>,
106    ) -> Arc<dyn OpDispenser> {
107        Arc::new(Self {
108            inner,
109            before_template,
110            after_template,
111            memo_state,
112        })
113    }
114
115    fn publish(&self, template: &str, wires: &dyn crate::wires::WireSource) {
116        match crate::wires::substitute_via_wires(template, wires) {
117            Ok(rendered) => {
118                self.memo_state.store(Arc::new(rendered));
119            }
120            Err(e) => {
121                crate::diag!(
122                    crate::observer::LogLevel::Debug,
123                    "memo: substitution failed for '{template}': {e}"
124                );
125            }
126        }
127    }
128}
129
130impl WrappingDispenser for MemoDispenser {}
131
132impl OpDispenser for MemoDispenser {
133    fn execute<'a>(
134        &'a self,
135        cycle: u64,
136        ctx: &'a crate::fixture::ExecCtx<'a>,
137    ) -> std::pin::Pin<
138        Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
139    > {
140        Box::pin(async move {
141            if let Some(t) = &self.before_template {
142                self.publish(t, ctx.wires);
143            }
144            let result = self.inner.execute(cycle, ctx).await?;
145            if let Some(t) = &self.after_template {
146                self.publish(t, ctx.wires);
147            }
148            Ok(result)
149        })
150    }
151
152    fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
153        Some(self.inner.as_ref())
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::adapter::{AdapterError, ExecutionError, OpResult, ResultBody};
161    use crate::fixture::{ExecCtx, ResolvedPulls};
162
163    #[derive(Debug)]
164    struct ResultDispBody {
165        value: serde_json::Value,
166        count: u64,
167    }
168    impl ResultBody for ResultDispBody {
169        fn to_json(&self) -> serde_json::Value {
170            self.value.clone()
171        }
172        fn as_any(&self) -> &dyn std::any::Any {
173            self
174        }
175        fn element_count(&self) -> u64 {
176            self.count
177        }
178    }
179
180    /// A canned-result inner dispenser. `body` controls the
181    /// successful path; `error` short-circuits to `ExecutionError`.
182    struct FakeInner {
183        body: Option<ResultDispBody>,
184        error: Option<&'static str>,
185    }
186
187    impl OpDispenser for FakeInner {
188        fn execute<'a>(
189            &'a self,
190            _cycle: u64,
191            _ctx: &'a ExecCtx<'a>,
192        ) -> std::pin::Pin<
193            Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
194        > {
195            Box::pin(async move {
196                if let Some(msg) = self.error {
197                    return Err(ExecutionError::Op(AdapterError {
198                        error_name: "test".into(),
199                        message: msg.into(),
200                        retryable: false,
201                    }));
202                }
203                Ok(OpResult {
204                    body: self.body.as_ref().map(|b| {
205                        Box::new(ResultDispBody {
206                            value: b.value.clone(),
207                            count: b.count,
208                        }) as Box<dyn ResultBody>
209                    }),
210                    skipped: false,
211                })
212            })
213        }
214    }
215
216    fn empty_ctx() -> (crate::adapter::ResolvedFields, ResolvedPulls) {
217        let fields = crate::adapter::ResolvedFields::new(vec![], vec![]);
218        let pulls = ResolvedPulls::empty();
219        (fields, pulls)
220    }
221
222    #[tokio::test]
223    async fn memo_wrapper_publishes_before_and_after() {
224        let memo = Arc::new(arc_swap::ArcSwap::from_pointee(String::new()));
225        let inner = Arc::new(FakeInner {
226            body: None,
227            error: None,
228        });
229        let dispenser = MemoDispenser::wrap(
230            inner,
231            Some("before-state".into()),
232            Some("after-state".into()),
233            memo.clone(),
234        );
235        let (fields, pulls) = empty_ctx();
236        let ctx = ExecCtx::new(&fields, &pulls);
237        let _ = dispenser.execute(0, &ctx).await.expect("inner ok");
238        assert_eq!(memo.load().as_str(), "after-state");
239    }
240
241    #[tokio::test]
242    async fn memo_wrapper_only_before_when_after_unset() {
243        let memo = Arc::new(arc_swap::ArcSwap::from_pointee(String::new()));
244        let inner = Arc::new(FakeInner {
245            body: None,
246            error: None,
247        });
248        let dispenser = MemoDispenser::wrap(inner, Some("ready".into()), None, memo.clone());
249        let (fields, pulls) = empty_ctx();
250        let ctx = ExecCtx::new(&fields, &pulls);
251        let _ = dispenser.execute(0, &ctx).await.expect("inner ok");
252        assert_eq!(memo.load().as_str(), "ready");
253    }
254
255    #[tokio::test]
256    async fn memo_wrapper_does_not_run_after_on_inner_error() {
257        let memo = Arc::new(arc_swap::ArcSwap::from_pointee(String::new()));
258        let inner = Arc::new(FakeInner {
259            body: None,
260            error: Some("boom"),
261        });
262        let dispenser = MemoDispenser::wrap(
263            inner,
264            Some("attempting".into()),
265            Some("finished".into()),
266            memo.clone(),
267        );
268        let (fields, pulls) = empty_ctx();
269        let ctx = ExecCtx::new(&fields, &pulls);
270        let res = dispenser.execute(0, &ctx).await;
271        assert!(res.is_err());
272        assert_eq!(
273            memo.load().as_str(),
274            "attempting",
275            "after-template must not run on inner error"
276        );
277    }
278}