WASM transform SDK
Write sandboxed WebAssembly transforms for rustcdc, with enforced memory and time limits.
On this page
Scope
This document defines the contract for running user-provided WASM transforms in rustcdc. The runtime provides a concrete execution engine with static contract validation, lifecycle hooks, memory IO, and timeout/memory guardrails.
ABI Contract
Host imports available to guest (env.*)
log(level: i32, ptr: i32, len: i32)get_metric(ptr: i32) -> i64record_metric(ptr: i32, value: i64)
Any import outside this set is rejected at load time by static analysis.
Guest exports
Required:
memoryalloc(size: i32) -> i32— allocatesizebytes; must never return 0 (address 0 is reserved)dealloc(ptr: i32, size: i32)— release the region[ptr .. ptr+size)transform(event_ptr: i32, event_len: i32) -> i64— see return semantics belowrustcdc_abi_version() -> i32— must return2
Optional:
init(config_ptr: i32, config_len: i32) -> i32shutdown() -> i32
Missing required exports or a wrong rustcdc_abi_version return value are hard errors at load time.
transform return semantics
The return value is a packed i64:
| Value | Meaning |
|---|---|
0 | Drop the event (filter-out). No output memory is allocated. |
(out_ptr << 32) | out_len | Transformed event. High 32 bits: output pointer. Low 32 bits: output byte length. |
out_ptrmust be > 0 (address 0 is reserved).out_lenmust be > 0 when a non-zero packed value is returned.- The bytes at
[out_ptr .. out_ptr+out_len)must deserialise into canonicalEventJSON. - The host calls
dealloc(out_ptr, out_len)after reading the output.
Memory ownership
- Host calls
alloc(event_len)→ getsinput_ptr. - Host writes serialised
EventJSON into[input_ptr .. input_ptr+event_len). - Host calls
transform(input_ptr, event_len)→ gets packedi64. - Host calls
dealloc(input_ptr, event_len)unconditionally. - If packed ≠ 0, host reads output, then calls
dealloc(out_ptr, out_len).
Event and Memory Model
- Event serialisation format: JSON.
- Input and output events must both be canonical
EventJSON. - Address 0 is reserved;
allocmust never return it.
Security and Reliability
- WASM runs sandboxed (no direct file I/O or network access).
- Static import scanning rejects all imports outside the three
env.*functions above. - Timeout enforced per transform invocation:
- default
50ms - configurable via
WasmConfig.timeout_ms
- default
- Memory limit enforced per runtime instance:
- default
16MB - configurable via
WasmConfig.memory_limit_mb
- default
- Traps are surfaced as
Error::TransformErrorat the call site.
Performance Targets
- Native overhead target:
< 5x - Per-event transform latency target:
< 1ms - Throughput target:
> 1K events/sec per transform instance
Threading Model and Concurrency
Each WasmRuntime instance is single-threaded. Internally, the WASM execution state is protected by a Mutex, so concurrent calls to transform() on the same instance serialize — only one event is being transformed at a time.
For a single-stream CDC pipeline this is not a bottleneck. However, if you are running high-throughput multi-table pipelines with WASM transforms, consider the following patterns:
Scaling with a WasmRuntime pool
Instantiate multiple WasmRuntime instances (one per logical shard or per available core) and dispatch events across them. Wasmtime module compilation is the expensive step; compile once and share the bytes.
# #[cfg(feature = "wasm")]
# fn example(config: rustcdc::WasmConfig, shards: usize) -> rustcdc::Result<()> {
use rustcdc::WasmRuntime;
// Compile once, share the bytes; one runtime instance per logical shard.
let pool: Vec<WasmRuntime> = (0..shards)
.map(|_| WasmRuntime::new_with_config(config.clone()))
.collect::<rustcdc::Result<Vec<_>>>()?;
// Dispatch: pick an instance by thread-local index or round-robin.
# let _ = pool;
# Ok(())
# }Key constraints
- Do not share a single
WasmRuntimeacross threads without external synchronization — doing so will serialize all transforms and nullify parallelism. - Each runtime instance owns its own linear memory space; guest state is not shared between pool members.
- Memory and timeout limits apply per-instance, per-invocation.
Rust API Reference
Implemented in src/wasm/runtime.rs:
WasmRuntimenew(wasm_module_path: &str) -> Result<Self>new_with_config(config: WasmConfig) -> Result<Self>init(&mut self) -> Result<()>transform(&mut self, event: &Event) -> Result<TransformResult>shutdown(&mut self) -> Result<()>config(&self) -> &WasmConfigmodule_size_bytes(&self) -> usizemetrics(&self) -> WasmRuntimeMetrics— point-in-time counter snapshot; see [WasmRuntimeMetrics] below
WasmRuntimeMetricsinstance_pool_size: usize— number of WASM instances in the pool (static after construction)transform_total: u64— total invocations attempted since inittransform_error_total: u64— total invocations that returned an errorfiltered_total: u64— total events dropped (filtered) by the moduletimeout_total: u64— total invocations that exceeded the configured timeout
TransformResultOk(Box<Event>)— transformed eventFiltered— event was dropped by the module (normal outcome)
WasmConfig{ module_path: PathBuf, timeout_ms: u64, memory_limit_mb: u64 }
Example Guest Transform Skeleton (Rust)
#[unsafe(no_mangle)]is the Rust 2024 spelling, which this page is compiled against. On edition 2021 and earlier, write#[no_mangle]— the attribute is otherwise identical. The edition of your guest crate is what decides, not rustcdc's.
use std::sync::atomic::{AtomicI32, Ordering};
static HEAP: AtomicI32 = AtomicI32::new(8); // address 0 is reserved
#[unsafe(no_mangle)]
pub extern "C" fn rustcdc_abi_version() -> i32 { 2 }
#[unsafe(no_mangle)]
pub extern "C" fn alloc(len: i32) -> i32 {
HEAP.fetch_add(len, Ordering::Relaxed)
}
#[unsafe(no_mangle)]
pub extern "C" fn dealloc(_ptr: i32, _len: i32) {} // no-op for bump allocator
#[unsafe(no_mangle)]
pub extern "C" fn init(_config_ptr: i32, _config_len: i32) -> i32 { 0 }
#[unsafe(no_mangle)]
pub extern "C" fn transform(event_ptr: i32, event_len: i32) -> i64 {
// 1. Read input bytes from [event_ptr .. event_ptr+event_len).
// 2. Parse, transform, serialise output.
// 3. Allocate output buffer via alloc(out_len).
// 4. Write output bytes into buffer.
// 5. Return packed: (out_ptr as i64) << 32 | (out_len as i64)
// or 0 to drop the event.
let _ = (event_ptr, event_len);
0 // drop the event (example: filter everything)
}
#[unsafe(no_mangle)]
pub extern "C" fn shutdown() -> i32 { 0 }Compilation Instructions
- Add target:
rustup target add wasm32-unknown-unknown
- Build module:
cargo build --release --target wasm32-unknown-unknownNon-Goals
- No full WASI runtime integration.
- No cross-module orchestration.
ABI Versioning and Deprecation Policy
The WASM guest ABI is versioned via the rustcdc_abi_version() export. The current ABI is v2.
Guarantees
| Guarantee | Policy |
|---|---|
| Breaking ABI bumps | Only introduced in minor or major crate version increments (never in patch releases). |
| Minimum notice period | A new ABI version is introduced as an opt-in accepted version first, alongside the current version. The old ABI version is accepted for at least one full minor-version cycle (i.e., until the next minor release after the new ABI stabilises). |
| Breaking rejection | The old ABI is rejected (hard load error) only in the next major crate version after the deprecation notice period ends. |
| Deprecation announcement | Announced via CHANGELOG.md, the crate-level #[doc] on validate_wasm_contract, and a tracing::warn! at module load time when a deprecated-but-still-accepted version is detected. |
Current version history
| ABI Version | Introduced | Status | Crate version range |
|---|---|---|---|
| v1 | < 0.5 | Removed (rejected at load) | < 0.5.0 |
| v2 | 0.5.0 | Current | ≥ 0.5.0 |
Migration guide: v2 → v3 (future)
When ABI v3 is introduced:
- The runtime will accept both v2 and v3 modules and emit a
WARNlog for v2 modules. - Guest modules must update
rustcdc_abi_version()to return3and adapt to any changed calling convention documented in the v3 release notes. - After one minor-version cycle, v2 modules will be rejected at load time with a structured
Error::ConfigErrorexplaining the required migration.
Checking your module's ABI version
# Inspect a compiled WASM module's exports:
wasm-objdump -x your_transform.wasm | grep rustcdc_abi_version
Or verify at runtime — the loader surfaces the detected version in the error message on rejection:
Error: ConfigError("WASM module ABI version mismatch: got 1, expected 2. \
Recompile against rustcdc >= 0.5.0 or use the abi_version = 2 skeleton.")