use std::path::Path;
use arrow_array::RecordBatch;
use arrow_schema::SchemaRef;
use crate::error::{BifrostError, Result};
use crate::mutation::{GraphMutation, VertexSet};
use crate::transform::{TableContext, Transform};
pub struct WasmTransform {
engine: wasmi::Engine,
module: wasmi::Module,
label: String,
id_column: String,
}
impl WasmTransform {
pub fn from_bytes(
wasm: &[u8],
label: impl Into<String>,
id_column: impl Into<String>,
) -> Result<Self> {
let engine = wasmi::Engine::default();
let module = wasmi::Module::new(&engine, wasm)
.map_err(|e| BifrostError::Other(format!("wasm compile: {e}")))?;
Ok(Self {
engine,
module,
label: label.into(),
id_column: id_column.into(),
})
}
pub fn from_file(
path: impl AsRef<Path>,
label: impl Into<String>,
id_column: impl Into<String>,
) -> Result<Self> {
let wasm = std::fs::read(path.as_ref())
.map_err(|e| BifrostError::Other(format!("read wasm {:?}: {e}", path.as_ref())))?;
Self::from_bytes(&wasm, label, id_column)
}
fn run(&self, input: &[u8]) -> Result<Vec<u8>> {
use wasmi::{Linker, Store};
let wasm_err = |m: String| BifrostError::Other(format!("wasm: {m}"));
let mut store = Store::new(&self.engine, ());
let linker = Linker::<()>::new(&self.engine);
let instance = linker
.instantiate_and_start(&mut store, &self.module)
.map_err(|e| wasm_err(format!("instantiate: {e}")))?;
let memory = instance
.get_memory(&store, "memory")
.ok_or_else(|| wasm_err("module exports no `memory`".into()))?;
let alloc = instance
.get_typed_func::<i32, i32>(&store, "alloc")
.map_err(|e| wasm_err(format!("no `alloc(i32)->i32`: {e}")))?;
let transform = instance
.get_typed_func::<(i32, i32), i64>(&store, "transform")
.map_err(|e| wasm_err(format!("no `transform(i32,i32)->i64`: {e}")))?;
let len = i32::try_from(input.len())
.map_err(|_| wasm_err("input too large for wasm32".into()))?;
let ptr = alloc
.call(&mut store, len)
.map_err(|e| wasm_err(format!("alloc: {e}")))?;
memory
.write(&mut store, ptr as usize, input)
.map_err(|e| wasm_err(format!("write input: {e}")))?;
let packed = transform
.call(&mut store, (ptr, len))
.map_err(|e| wasm_err(format!("transform: {e}")))?;
let out_ptr = ((packed >> 32) & 0xFFFF_FFFF) as usize;
let out_len = (packed & 0xFFFF_FFFF) as usize;
let mut out = vec![0u8; out_len];
memory
.read(&store, out_ptr, &mut out)
.map_err(|e| wasm_err(format!("read output: {e}")))?;
Ok(out)
}
}
impl Transform for WasmTransform {
fn apply(&self, _ctx: &TableContext, batches: &[RecordBatch]) -> Result<GraphMutation> {
let Some(first) = batches.first() else {
return Ok(GraphMutation::default());
};
let input = write_ipc(first.schema(), batches)?;
let output = self.run(&input)?;
let out_batches = read_ipc(&output)?;
let vertices = out_batches
.into_iter()
.map(|batch| VertexSet {
label: self.label.clone(),
id_column: self.id_column.clone(),
batch,
})
.collect();
Ok(GraphMutation {
edge_deletes: Vec::new(),
vertices,
..Default::default()
})
}
}
fn write_ipc(schema: SchemaRef, batches: &[RecordBatch]) -> Result<Vec<u8>> {
let mut buf = Vec::new();
{
let mut w = arrow_ipc::writer::StreamWriter::try_new(&mut buf, &schema)
.map_err(|e| BifrostError::Other(format!("ipc writer: {e}")))?;
for b in batches {
w.write(b)
.map_err(|e| BifrostError::Other(format!("ipc write: {e}")))?;
}
w.finish()
.map_err(|e| BifrostError::Other(format!("ipc finish: {e}")))?;
}
Ok(buf)
}
fn read_ipc(bytes: &[u8]) -> Result<Vec<RecordBatch>> {
let reader = arrow_ipc::reader::StreamReader::try_new(std::io::Cursor::new(bytes), None)
.map_err(|e| BifrostError::Other(format!("ipc reader: {e}")))?;
reader
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| BifrostError::Other(format!("ipc read: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use arrow_array::{Int64Array, StringArray};
use arrow_schema::{DataType, Field, Schema};
const IDENTITY_WAT: &str = r#"
(module
(memory (export "memory") 32)
(global $heap (mut i32) (i32.const 65536))
(func (export "alloc") (param $len i32) (result i32)
(local $p i32)
(local.set $p (global.get $heap))
(global.set $heap (i32.add (global.get $heap) (local.get $len)))
(local.get $p))
(func (export "transform") (param $ptr i32) (param $len i32) (result i64)
(i64.or
(i64.shl (i64.extend_i32_u (local.get $ptr)) (i64.const 32))
(i64.extend_i32_u (local.get $len)))))
"#;
fn sample() -> RecordBatch {
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("_gar_id", DataType::Int64, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["a", "b", "c"])),
],
)
.unwrap()
}
#[test]
fn wasm_identity_transform_round_trips_to_vertices() {
let wasm = wat::parse_str(IDENTITY_WAT).expect("wat compiles");
let t = WasmTransform::from_bytes(&wasm, "Person", "_gar_id").unwrap();
let batch = sample();
let mutation = t
.apply(&TableContext { table: "t".into() }, &[batch.clone()])
.unwrap();
assert_eq!(
mutation.vertices.len(),
1,
"one output batch → one vertex set"
);
let vs = &mutation.vertices[0];
assert_eq!(vs.label, "Person");
assert_eq!(vs.id_column, "_gar_id");
assert_eq!(vs.batch.num_rows(), 3);
assert_eq!(
vs.batch, batch,
"identity guest echoes the rows through the IPC bridge"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"wasm::wasm_identity_transform_round_trips_to_vertices",
vs.label == "Person" && vs.batch.num_rows() == 3 && vs.batch == batch,
&format!("identity guest echoed {} rows via IPC", vs.batch.num_rows()),
);
}
#[test]
fn missing_export_is_a_clean_error() {
let wasm = wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap();
let t = WasmTransform::from_bytes(&wasm, "X", "_gar_id").unwrap();
let err = t
.apply(&TableContext { table: "t".into() }, &[sample()])
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("alloc"), "got: {err}");
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"wasm::missing_export_is_a_clean_error",
msg.contains("alloc"),
"missing export → descriptive error, not panic",
);
}
}