Skip to main content

faucet_transform_wasm/
runtime.rs

1//! The compiled WASM transform: owns the engine/module (shared across pages)
2//! and adapts it to a page-level [`TransformStage::PageFn`].
3
4use crate::config::{WasmOnError, WasmTransformConfig};
5use crate::engine::WasmEngine;
6use crate::instance::Outcome;
7use crate::metrics;
8use faucet_core::FaucetError;
9use faucet_core::stage::TransformStage;
10use serde_json::Value;
11use std::sync::{Arc, Mutex};
12use std::time::Instant;
13
14/// A compiled WASM transform. The engine + module are compiled once and reused
15/// across the row's pages; each page gets a fresh instance.
16pub struct WasmTransform {
17    engine: Arc<Mutex<WasmEngine>>,
18    on_error: WasmOnError,
19    module_label: String,
20}
21
22impl std::fmt::Debug for WasmTransform {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.debug_struct("WasmTransform")
25            .field("module", &self.module_label)
26            .field("on_error", &self.on_error)
27            .finish_non_exhaustive()
28    }
29}
30
31impl WasmTransform {
32    /// Compile the module, validate its ABI, and build the reusable engine.
33    /// Fails fast on a missing file, malformed module, or missing exports.
34    pub fn compile(cfg: &WasmTransformConfig) -> Result<Self, FaucetError> {
35        let engine = WasmEngine::compile(cfg)?;
36        Ok(Self {
37            module_label: engine.module_label.clone(),
38            engine: Arc::new(Mutex::new(engine)),
39            on_error: cfg.on_error,
40        })
41    }
42
43    /// Consume into a page-level transform stage.
44    pub fn into_page_stage(self) -> TransformStage {
45        let engine = self.engine;
46        let on_error = self.on_error;
47        let module_label = self.module_label;
48        TransformStage::PageFn(Arc::new(move |records: Vec<Value>| {
49            execute_page(&engine, on_error, &module_label, records)
50        }))
51    }
52
53    #[cfg(test)]
54    fn run_page(&self, records: Vec<Value>) -> Result<Vec<Value>, FaucetError> {
55        execute_page(&self.engine, self.on_error, &self.module_label, records)
56    }
57}
58
59fn execute_page(
60    engine: &Arc<Mutex<WasmEngine>>,
61    on_error: WasmOnError,
62    module_label: &str,
63    records: Vec<Value>,
64) -> Result<Vec<Value>, FaucetError> {
65    if records.is_empty() {
66        return Ok(Vec::new());
67    }
68    let mut eng = engine.lock().unwrap_or_else(|e| e.into_inner());
69    eng.reload_if_changed();
70    let mut inst = eng.new_page_instance()?;
71
72    let mut out = Vec::with_capacity(records.len());
73    for rec in records {
74        let input = serde_json::to_vec(&rec).map_err(|e| {
75            FaucetError::Transform(format!("wasm transform: serialize record: {e}"))
76        })?;
77        let start = Instant::now();
78        let result = inst.run(&input)?;
79        metrics::invocation_duration(module_label, start.elapsed().as_secs_f64());
80        metrics::fuel_consumed(module_label, result.fuel_consumed);
81
82        let err_msg: String = match result.outcome {
83            Outcome::Emit(bytes) => match serde_json::from_slice::<Value>(&bytes) {
84                Ok(v) => {
85                    metrics::invocation(module_label, "ok");
86                    out.push(v);
87                    continue;
88                }
89                Err(e) => format!("wasm transform: module returned invalid JSON: {e}"),
90            },
91            Outcome::Drop => {
92                metrics::invocation(module_label, "filter");
93                continue;
94            }
95            Outcome::Error(msg) => msg,
96        };
97
98        // Error path — apply the on_error policy.
99        metrics::invocation(module_label, "error");
100        match on_error {
101            WasmOnError::Fail => return Err(FaucetError::Transform(err_msg)),
102            WasmOnError::Skip => tracing::warn!(
103                target: "faucet::transform::wasm",
104                module = %module_label,
105                error = %err_msg,
106                "dropping record after wasm error (on_error: skip)"
107            ),
108            WasmOnError::Passthrough => {
109                tracing::warn!(
110                    target: "faucet::transform::wasm",
111                    module = %module_label,
112                    error = %err_msg,
113                    "passing record through unchanged after wasm error (on_error: passthrough)"
114                );
115                out.push(rec);
116            }
117        }
118    }
119    metrics::memory_bytes(module_label, inst.peak_memory());
120    Ok(out)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use serde_json::json;
127    use std::io::Write;
128    use tempfile::NamedTempFile;
129
130    // ── WAT fixture modules implementing the v1 ABI ──────────────────────────
131
132    /// Bump-allocator preamble shared by the fixtures: `memory` + `alloc`.
133    const PREAMBLE: &str = r#"
134        (memory (export "memory") 1)
135        (global $bump (mut i32) (i32.const 1024))
136        (func (export "alloc") (param $len i32) (result i32)
137            (local $p i32)
138            (local.set $p (global.get $bump))
139            (global.set $bump (i32.add (global.get $bump) (local.get $len)))
140            (local.get $p))
141    "#;
142
143    /// Echoes the input JSON back verbatim (proves the ABI round-trip).
144    fn identity_wat() -> String {
145        format!(
146            r#"(module {PREAMBLE}
147            (func (export "transform") (param $ptr i32) (param $len i32) (result i64)
148                (local $out i32)
149                (local.set $out (global.get $bump))
150                (global.set $bump (i32.add (global.get $bump) (local.get $len)))
151                (memory.copy (local.get $out) (local.get $ptr) (local.get $len))
152                (i64.or
153                    (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
154                    (i64.extend_i32_u (local.get $len)))))"#
155        )
156    }
157
158    /// Returns the drop sentinel (0) — filters every record.
159    fn drop_wat() -> String {
160        format!(
161            r#"(module {PREAMBLE}
162            (func (export "transform") (param i32) (param i32) (result i64)
163                (i64.const 0)))"#
164        )
165    }
166
167    /// Signals an error (returns u64::MAX) with an error message.
168    fn error_wat() -> String {
169        format!(
170            r#"(module {PREAMBLE}
171            (data (i32.const 100) "boom in module")
172            (func (export "transform") (param i32) (param i32) (result i64)
173                (i64.const -1))
174            (func (export "error_ptr") (result i32) (i32.const 100))
175            (func (export "error_len") (result i32) (i32.const 14)))"#
176        )
177    }
178
179    /// Signals an error but exports no `error_ptr`/`error_len`.
180    fn error_no_msg_wat() -> String {
181        format!(
182            r#"(module {PREAMBLE}
183            (func (export "transform") (param i32) (param i32) (result i64)
184                (i64.const -1)))"#
185        )
186    }
187
188    /// Spins forever — exhausts fuel.
189    fn fuel_bomb_wat() -> String {
190        format!(
191            r#"(module {PREAMBLE}
192            (func (export "transform") (param i32) (param i32) (result i64)
193                (loop $l (br $l))
194                (i64.const 0)))"#
195        )
196    }
197
198    /// Grows memory far past any cap — traps under trap_on_grow_failure.
199    fn mem_bomb_wat() -> String {
200        format!(
201            r#"(module {PREAMBLE}
202            (func (export "transform") (param i32) (param i32) (result i64)
203                (drop (memory.grow (i32.const 100000)))
204                (i64.const 0)))"#
205        )
206    }
207
208    /// Uses the host imports (`now_ns`, `log`) then echoes the record.
209    fn host_wat() -> String {
210        format!(
211            r#"(module
212            (import "faucet_v1" "log" (func $log (param i32 i32 i32)))
213            (import "faucet_v1" "now_ns" (func $now (result i64)))
214            {PREAMBLE}
215            (func (export "transform") (param $ptr i32) (param $len i32) (result i64)
216                (local $out i32)
217                (drop (call $now))
218                (call $log (i32.const 3) (local.get $ptr) (local.get $len))
219                (local.set $out (global.get $bump))
220                (global.set $bump (i32.add (global.get $bump) (local.get $len)))
221                (memory.copy (local.get $out) (local.get $ptr) (local.get $len))
222                (i64.or
223                    (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
224                    (i64.extend_i32_u (local.get $len)))))"#
225        )
226    }
227
228    /// Returns an out-of-bounds output pointer.
229    fn bad_ptr_wat() -> String {
230        format!(
231            r#"(module {PREAMBLE}
232            (func (export "transform") (param i32) (param i32) (result i64)
233                (i64.or (i64.shl (i64.const 1000000) (i64.const 32)) (i64.const 10))))"#
234        )
235    }
236
237    /// Emits bytes that are not valid JSON.
238    fn invalid_json_wat() -> String {
239        format!(
240            r#"(module {PREAMBLE}
241            (data (i32.const 200) "notjson")
242            (func (export "transform") (param i32) (param i32) (result i64)
243                (i64.or (i64.shl (i64.const 200) (i64.const 32)) (i64.const 7))))"#
244        )
245    }
246
247    /// Free-exporting echo — exercises the `free` reclamation path.
248    fn identity_with_free_wat() -> String {
249        format!(
250            r#"(module {PREAMBLE}
251            (func (export "free") (param i32) (param i32))
252            (func (export "transform") (param $ptr i32) (param $len i32) (result i64)
253                (local $out i32)
254                (local.set $out (global.get $bump))
255                (global.set $bump (i32.add (global.get $bump) (local.get $len)))
256                (memory.copy (local.get $out) (local.get $ptr) (local.get $len))
257                (i64.or
258                    (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
259                    (i64.extend_i32_u (local.get $len)))))"#
260        )
261    }
262
263    /// Calls `log` at every level (incl. an out-of-bounds pointer) then echoes.
264    fn log_levels_wat() -> String {
265        format!(
266            r#"(module
267            (import "faucet_v1" "log" (func $log (param i32 i32 i32)))
268            {PREAMBLE}
269            (func (export "transform") (param $ptr i32) (param $len i32) (result i64)
270                (local $out i32)
271                (call $log (i32.const 0) (local.get $ptr) (local.get $len))
272                (call $log (i32.const 1) (local.get $ptr) (local.get $len))
273                (call $log (i32.const 2) (local.get $ptr) (local.get $len))
274                (call $log (i32.const 4) (i32.const 5000000) (i32.const 4))
275                (local.set $out (global.get $bump))
276                (global.set $bump (i32.add (global.get $bump) (local.get $len)))
277                (memory.copy (local.get $out) (local.get $ptr) (local.get $len))
278                (i64.or
279                    (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
280                    (i64.extend_i32_u (local.get $len)))))"#
281        )
282    }
283
284    /// `alloc` traps immediately.
285    fn alloc_trap_wat() -> &'static str {
286        r#"(module
287            (memory (export "memory") 1)
288            (func (export "alloc") (param i32) (result i32) unreachable)
289            (func (export "transform") (param i32) (param i32) (result i64) (i64.const 0)))"#
290    }
291
292    /// `alloc` returns an out-of-bounds offset, so the host's input write fails.
293    fn bad_alloc_ptr_wat() -> &'static str {
294        r#"(module
295            (memory (export "memory") 1)
296            (func (export "alloc") (param i32) (result i32) (i32.const 5000000))
297            (func (export "transform") (param i32) (param i32) (result i64) (i64.const 0)))"#
298    }
299
300    /// Errors, but points `error_ptr`/`error_len` out of bounds.
301    fn error_oob_wat() -> String {
302        format!(
303            r#"(module {PREAMBLE}
304            (func (export "transform") (param i32) (param i32) (result i64) (i64.const -1))
305            (func (export "error_ptr") (result i32) (i32.const 5000000))
306            (func (export "error_len") (result i32) (i32.const 10)))"#
307        )
308    }
309
310    // ── helpers ──────────────────────────────────────────────────────────────
311
312    fn write_wasm(wat: &str) -> NamedTempFile {
313        let wasm = wat::parse_str(wat).expect("valid wat");
314        let mut f = NamedTempFile::new().unwrap();
315        f.write_all(&wasm).unwrap();
316        f.flush().unwrap();
317        f
318    }
319
320    fn cfg(path: &std::path::Path) -> WasmTransformConfig {
321        WasmTransformConfig {
322            module: path.to_string_lossy().into_owned(),
323            function: "transform".into(),
324            memory_limit_mb: 16,
325            fuel_limit: 1_000_000,
326            on_error: WasmOnError::Fail,
327            reload_on_change: false,
328        }
329    }
330
331    /// Compile a transform from WAT with an optional config tweak. Returns the
332    /// transform plus the backing temp file (keep it alive for the test).
333    fn build(
334        wat: &str,
335        tweak: impl FnOnce(&mut WasmTransformConfig),
336    ) -> (WasmTransform, NamedTempFile) {
337        let f = write_wasm(wat);
338        let mut c = cfg(f.path());
339        tweak(&mut c);
340        let t = WasmTransform::compile(&c).expect("compile");
341        (t, f)
342    }
343
344    // ── tests ────────────────────────────────────────────────────────────────
345
346    #[test]
347    fn identity_round_trips_records() {
348        let (t, _f) = build(&identity_wat(), |_| {});
349        let out = t
350            .run_page(vec![json!({"id": 1, "name": "a"}), json!({"id": 2})])
351            .unwrap();
352        assert_eq!(out, vec![json!({"id": 1, "name": "a"}), json!({"id": 2})]);
353    }
354
355    #[test]
356    fn identity_with_free_round_trips() {
357        let (t, _f) = build(&identity_with_free_wat(), |_| {});
358        let out = t.run_page(vec![json!({"x": 42})]).unwrap();
359        assert_eq!(out, vec![json!({"x": 42})]);
360    }
361
362    #[test]
363    fn empty_page_is_identity() {
364        let (t, _f) = build(&identity_wat(), |_| {});
365        assert_eq!(t.run_page(vec![]).unwrap(), Vec::<Value>::new());
366    }
367
368    #[test]
369    fn drop_filters_every_record() {
370        let (t, _f) = build(&drop_wat(), |_| {});
371        let out = t.run_page(vec![json!({"a": 1}), json!({"a": 2})]).unwrap();
372        assert_eq!(out, Vec::<Value>::new());
373    }
374
375    #[test]
376    fn error_with_message_fails_by_default() {
377        let (t, _f) = build(&error_wat(), |_| {});
378        let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
379        let msg = format!("{err}");
380        assert!(msg.contains("boom in module"), "{msg}");
381    }
382
383    #[test]
384    fn error_without_message_still_reports() {
385        let (t, _f) = build(&error_no_msg_wat(), |_| {});
386        let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
387        assert!(format!("{err}").contains("no error_ptr"), "{err}");
388    }
389
390    #[test]
391    fn on_error_skip_drops_failing_record() {
392        let (t, _f) = build(&error_wat(), |c| c.on_error = WasmOnError::Skip);
393        let out = t.run_page(vec![json!({"a": 1})]).unwrap();
394        assert_eq!(out, Vec::<Value>::new());
395    }
396
397    #[test]
398    fn on_error_passthrough_keeps_original() {
399        let (t, _f) = build(&error_wat(), |c| c.on_error = WasmOnError::Passthrough);
400        let out = t.run_page(vec![json!({"a": 1})]).unwrap();
401        assert_eq!(out, vec![json!({"a": 1})]);
402    }
403
404    #[test]
405    fn fuel_exhaustion_is_an_error() {
406        let (t, _f) = build(&fuel_bomb_wat(), |c| {
407            c.fuel_limit = 10_000;
408            c.on_error = WasmOnError::Skip;
409        });
410        // Skip policy → the failing record is dropped, no records emitted.
411        let out = t.run_page(vec![json!({"a": 1})]).unwrap();
412        assert_eq!(out, Vec::<Value>::new());
413    }
414
415    #[test]
416    fn fuel_exhaustion_message_mentions_fuel() {
417        let (t, _f) = build(&fuel_bomb_wat(), |c| c.fuel_limit = 10_000);
418        let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
419        assert!(format!("{err}").to_lowercase().contains("fuel"), "{err}");
420    }
421
422    #[test]
423    fn memory_cap_is_an_error() {
424        let (t, _f) = build(&mem_bomb_wat(), |c| {
425            c.memory_limit_mb = 1;
426            c.on_error = WasmOnError::Passthrough;
427        });
428        let out = t.run_page(vec![json!({"a": 1})]).unwrap();
429        // Passthrough → original record survives despite the memory trap.
430        assert_eq!(out, vec![json!({"a": 1})]);
431    }
432
433    #[test]
434    fn host_imports_work() {
435        let (t, _f) = build(&host_wat(), |_| {});
436        let out = t.run_page(vec![json!({"hello": "world"})]).unwrap();
437        assert_eq!(out, vec![json!({"hello": "world"})]);
438    }
439
440    #[test]
441    fn out_of_bounds_output_is_an_error() {
442        let (t, _f) = build(&bad_ptr_wat(), |c| c.on_error = WasmOnError::Skip);
443        assert_eq!(
444            t.run_page(vec![json!({"a": 1})]).unwrap(),
445            Vec::<Value>::new()
446        );
447    }
448
449    #[test]
450    fn invalid_json_output_is_an_error() {
451        let (t, _f) = build(&invalid_json_wat(), |_| {});
452        let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
453        assert!(format!("{err}").contains("invalid JSON"), "{err}");
454    }
455
456    #[test]
457    fn missing_module_file_fails_to_compile() {
458        let c = WasmTransformConfig {
459            module: "/no/such/module.wasm".into(),
460            function: "transform".into(),
461            memory_limit_mb: 16,
462            fuel_limit: 1000,
463            on_error: WasmOnError::Fail,
464            reload_on_change: false,
465        };
466        let err = WasmTransform::compile(&c).unwrap_err();
467        assert!(format!("{err}").contains("cannot read module"), "{err}");
468    }
469
470    #[test]
471    fn malformed_module_fails_to_compile() {
472        let mut f = NamedTempFile::new().unwrap();
473        f.write_all(b"not a wasm module").unwrap();
474        f.flush().unwrap();
475        let err = WasmTransform::compile(&cfg(f.path())).unwrap_err();
476        assert!(format!("{err}").contains("compile"), "{err}");
477    }
478
479    #[test]
480    fn missing_alloc_export_fails_to_compile() {
481        let wat = r#"(module
482            (memory (export "memory") 1)
483            (func (export "transform") (param i32) (param i32) (result i64) (i64.const 0)))"#;
484        let f = write_wasm(wat);
485        let err = WasmTransform::compile(&cfg(f.path())).unwrap_err();
486        assert!(format!("{err}").contains("alloc"), "{err}");
487    }
488
489    #[test]
490    fn missing_transform_function_fails_to_compile() {
491        let f = write_wasm(&drop_wat());
492        let mut c = cfg(f.path());
493        c.function = "does_not_exist".into();
494        let err = WasmTransform::compile(&c).unwrap_err();
495        assert!(format!("{err}").contains("does_not_exist"), "{err}");
496    }
497
498    #[test]
499    fn missing_memory_export_fails_to_compile() {
500        let wat = r#"(module
501            (func (export "alloc") (param i32) (result i32) (i32.const 0))
502            (func (export "transform") (param i32) (param i32) (result i64) (i64.const 0)))"#;
503        let f = write_wasm(wat);
504        let err = WasmTransform::compile(&cfg(f.path())).unwrap_err();
505        assert!(format!("{err}").contains("memory"), "{err}");
506    }
507
508    #[test]
509    fn into_page_stage_wires_through_core() {
510        // Drive the real faucet-core page runner to cover into_page_stage().
511        let (t, _f) = build(&identity_wat(), |_| {});
512        let stage = t.into_page_stage();
513        let compiled = faucet_core::compile_stage(&stage).unwrap();
514        let out =
515            faucet_core::stage::apply_stages_to_page(vec![json!({"k": "v"})], &[compiled]).unwrap();
516        assert_eq!(out, vec![json!({"k": "v"})]);
517    }
518
519    #[test]
520    fn debug_impl_names_module() {
521        let (t, _f) = build(&identity_wat(), |_| {});
522        assert!(format!("{t:?}").contains("WasmTransform"));
523    }
524
525    #[test]
526    fn reload_on_change_swaps_module() {
527        // Start as identity, then overwrite with a drop module and confirm the
528        // next page reloads it.
529        let f = write_wasm(&identity_wat());
530        let mut c = cfg(f.path());
531        c.reload_on_change = true;
532        let t = WasmTransform::compile(&c).unwrap();
533        assert_eq!(
534            t.run_page(vec![json!({"a": 1})]).unwrap(),
535            vec![json!({"a": 1})]
536        );
537
538        // Bump mtime and rewrite as a drop module.
539        std::thread::sleep(std::time::Duration::from_millis(20));
540        std::fs::write(f.path(), wat::parse_str(drop_wat()).unwrap()).unwrap();
541
542        assert_eq!(
543            t.run_page(vec![json!({"a": 1})]).unwrap(),
544            Vec::<Value>::new()
545        );
546    }
547
548    #[test]
549    fn log_at_all_levels_including_bad_pointer() {
550        let (t, _f) = build(&log_levels_wat(), |_| {});
551        let out = t.run_page(vec![json!({"msg": "hi"})]).unwrap();
552        assert_eq!(out, vec![json!({"msg": "hi"})]);
553    }
554
555    #[test]
556    fn alloc_trap_is_an_error() {
557        let (t, _f) = build(alloc_trap_wat(), |c| c.on_error = WasmOnError::Skip);
558        assert_eq!(
559            t.run_page(vec![json!({"a": 1})]).unwrap(),
560            Vec::<Value>::new()
561        );
562    }
563
564    #[test]
565    fn bad_alloc_pointer_fails_input_write() {
566        let (t, _f) = build(bad_alloc_ptr_wat(), |c| c.on_error = WasmOnError::Skip);
567        assert_eq!(
568            t.run_page(vec![json!({"a": 1})]).unwrap(),
569            Vec::<Value>::new()
570        );
571    }
572
573    #[test]
574    fn error_pointer_out_of_bounds_still_reports() {
575        let (t, _f) = build(&error_oob_wat(), |_| {});
576        let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
577        assert!(format!("{err}").contains("out of bounds"), "{err}");
578    }
579
580    #[test]
581    fn reload_keeps_last_good_when_file_deleted() {
582        let f = write_wasm(&identity_wat());
583        let path = f.path().to_path_buf();
584        let mut c = cfg(&path);
585        c.reload_on_change = true;
586        let t = WasmTransform::compile(&c).unwrap();
587
588        // Remove the file; the reload attempt must fail-soft and keep identity.
589        std::thread::sleep(std::time::Duration::from_millis(20));
590        std::fs::remove_file(&path).unwrap();
591
592        assert_eq!(
593            t.run_page(vec![json!({"stay": 1})]).unwrap(),
594            vec![json!({"stay": 1})]
595        );
596    }
597
598    #[test]
599    fn reload_keeps_last_good_on_bad_module() {
600        let f = write_wasm(&identity_wat());
601        let mut c = cfg(f.path());
602        c.reload_on_change = true;
603        let t = WasmTransform::compile(&c).unwrap();
604
605        // Overwrite with garbage; the reload must fail and keep identity.
606        std::thread::sleep(std::time::Duration::from_millis(20));
607        std::fs::write(f.path(), b"garbage not wasm").unwrap();
608
609        assert_eq!(
610            t.run_page(vec![json!({"keep": true})]).unwrap(),
611            vec![json!({"keep": true})]
612        );
613    }
614}