WASM Procedures (User-Defined Functions)
patinaDB lets you register your own read-only procedures compiled to
WebAssembly — callable from Cypher as CALL wasm.<name>(args), just like a
built-in procedure. This is patinaDB’s user-defined-function story: instead of
an ad-hoc scripting language, a UDF is a compiled .wasm module, which means
it can be written in any language that compiles to WebAssembly (Rust, C/C++,
AssemblyScript, Zig, …) and runs deterministically and sandboxed — the
property that makes it safe to replicate to every node in a cluster.
CREATE FUNCTION fib (1) LANGUAGE wasm FROM 'file:///opt/patinadb/fib.wasm' EXPORT 'fib';
CALL wasm.fib(10) YIELD result RETURN result;
-- result: 55
Why WASM
Three engines were evaluated (Rhai, LuaJIT, WASM/wasmtime) for embeddable user-defined logic. WASM won on both axes that matter for a replicated database:
- Speed. Near-native on compute, and the fastest scripted call of the three.
- Determinism by construction. A WASM guest module can be sandboxed so it imports nothing from its host — no clock, no RNG, no filesystem, no network. With host imports denied, the same module given the same input produces the same output on every run and every node, every time. That is the property a Raft-replicated write path requires, and it is why patinaDB doesn’t offer a general scripting language as its UDF surface.
A WASM function is currently read-only: pure compute over its scalar arguments, plus (as of increment 2) bounded point-reads against the graph. It cannot write, and cannot reach the filesystem, network, clock, or any randomness source.
Runtime security updates
The bundled runtime requires Wasmtime 48.0.7 or newer in the 48.x series. This includes the fixes for RUSTSEC-2026-0378 and RUSTSEC-2026-0379. These dependency updates preserve the procedure ABI and sandbox limits; they do not require re-registering stored functions.
Registering a function
CREATE FUNCTION [IF NOT EXISTS] <name> [(<arity>)] LANGUAGE wasm
FROM '<file-url>' EXPORT '<entry-export-name>'
DROP FUNCTION [IF EXISTS] <name>
SHOW FUNCTIONS
<name>is a bare identifier (or a backtick-quoted one) and becomes callable asCALL wasm.<name>(...).<arity>is optional and documents the expected argument count; it’s not currently enforced beyond what the guest’s ABI decoding does.LANGUAGE wasmis required — it’s the only language patinaDB currently compiles, so this is future-proofing the grammar rather than a real choice today.FROM '<file-url>'is afile://URL. The module’s bytes are read once, atCREATE FUNCTIONtime, compiled, validated (see Sandbox & determinism below), and then stored — the file itself is never consulted again. This matters for two reasons: a reopened database re-activates the function with no dependency on the original file still existing, and on a cluster the compiled module’s bytes (not the path) are what gets replicated — a follower obviously can’t read a file that only exists on the leader’s disk.EXPORT '<entry-export-name>'names the guest function to call — see the ABI section below for what that function must look like.- A module that fails to compile, imports anything not on the allowed list
(see below), or doesn’t export the required ABI surface, is rejected at
CREATE FUNCTIONtime with a clear error, and nothing is persisted.
DROP FUNCTION removes the definition and makes wasm.<name> uncallable.
SHOW FUNCTIONS lists what’s registered.
A worked example
Here is a minimal, complete fib guest written directly in WebAssembly text
format (WAT) — small enough to read end to end, and a realistic shape for
whatever toolchain compiles your language of choice to .wasm. Save it as
fib.wat, assemble it to fib.wasm with wat2wasm (from
WABT), and register it:
(module
(memory (export "memory") 1)
(global $bump (mut i32) (i32.const 1024))
;; A bump allocator — the host uses this to hand the guest its own args.
(func $alloc (export "alloc") (param $n i32) (result i32)
(local $p i32)
(local.set $p (global.get $bump))
(global.set $bump (i32.add (global.get $bump) (local.get $n)))
(local.get $p))
;; fib(n): Int -> Int
(func (export "fib") (param $in i32) (param $len i32) (result i64)
(local $n i64) (local $a i64) (local $b i64) (local $t i64) (local $i i64)
(local $out i32)
(local.set $n (i64.load align=1 (i32.add (local.get $in) (i32.const 5))))
(local.set $a (i64.const 0))
(local.set $b (i64.const 1))
(local.set $i (i64.const 0))
(block $done
(loop $loop
(br_if $done (i64.ge_s (local.get $i) (local.get $n)))
(local.set $t (i64.add (local.get $a) (local.get $b)))
(local.set $a (local.get $b))
(local.set $b (local.get $t))
(local.set $i (i64.add (local.get $i) (i64.const 1)))
(br $loop)))
(local.set $out (call $alloc (i32.const 9)))
(i32.store8 (local.get $out) (i32.const 1))
(i64.store align=1 (i32.add (local.get $out) (i32.const 1)) (local.get $a))
(i64.or (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32)) (i64.const 9))))
CREATE FUNCTION fib (1) LANGUAGE wasm FROM 'file:///opt/patinadb/fib.wasm' EXPORT 'fib';
CALL wasm.fib(10) YIELD result RETURN result;
-- result: 55
CALL wasm.fib(20) YIELD result RETURN result;
-- result: 6765
If you’re writing the guest in Rust instead of hand-rolled WAT, the same ABI
applies: export memory, alloc, and your entry function with the exact
signatures in The ABI below; the wasm32-unknown-unknown
target with #[no_std] (or a thin marshalling shim over std) compiles
cleanly to a module with no imports.
Reading the graph from a function (increment 2)
A WASM function isn’t limited to its scalar arguments — it can also do
bounded point-reads against the graph it’s called from, over a
consistent snapshot of the database’s current committed state (HEAD). This
lets a function compute over a node’s properties, labels, or neighbours
without patinaDB having to expose general graph traversal to the sandbox.
A guest opts in by importing exactly these three host functions from the
module "patinadb" (any other import — a WASI clock, env anything — is
rejected at CREATE FUNCTION time):
| Host function | Signature | Returns |
|---|---|---|
node_get_property | (id_ptr, prop_ptr, prop_len) -> i64 | The property’s value (Null if absent). |
node_labels | (id_ptr) -> i64 | A List of the node’s labels as strings. |
node_neighbors | (id_ptr, dir, rel_ptr, rel_len) -> i64 | A List of neighbouring node UUIDs. dir: 0=out, 1=in, 2=both. rel_len=0 means any relationship type. |
Call one of these by passing a node argument through to your entry function —
CALL wasm.readScore(n) evaluates n to the node’s UUID, which the entry
function then hands to node_get_property. See
crates/patinadb-wasm/examples/guest.wat in the repository for a complete
reference guest that imports all three.
Bounded, so a function can’t read the whole graph unboundedly: each host
call counts against a per-invocation budget
(PATINADB_WASM_MAX_GRAPH_READS, default 100,000 calls) — once exceeded the
guest traps with a clean error, never a hang. node_neighbors is additionally
capped at a maximum number of returned neighbour ids per call. A malformed
request (an out-of-bounds pointer, an unknown direction, or calling these
outside of a query context) is also a clean trap, never a panic or a crash.
Still read-only: none of these host functions can create, update, or delete anything.
Determinism & the sandbox
Pure computation is deterministic for the same module and arguments. Graph-reading procedures additionally depend on the pinned committed graph state; identical arguments on replicas at different applied positions need not return the same value. Runtime safety limits can also reject a call rather than return a value. patinaDB enforces the following sandbox boundaries:
- No host imports beyond the documented allow-list. A guest module is instantiated with an empty import list by default; the only imports ever accepted are the three graph-read functions above, and only when a graph context is available. Anything else — a WASI clock, a random-number source, a filesystem call, an environment-variable read — is rejected at registration, before the module is ever run.
- Fuel is the deterministic resource bound. Every invocation gets a fixed instruction budget (100,000,000 units by default). The same module and input consume the same fuel and trap at the same point on every node — this is what makes an accidental infinite loop safe to replicate rather than a cluster-wedging hang.
- Wall-clock (
epoch) and memory are safety nets, not the primary bound. A 5-second wall-clock deadline and a 16 MiB linear-memory cap protect a single node against a runaway guest, but neither is used to gate a replicated write, because wall-clock timing is inherently nondeterministic across machines. - The table-element count is capped too (65,536 entries). A guest’s
call_indirectdispatch table is separate memory, so the linear-memory cap alone never governed it; without a ceiling a module could declare a billions-of-entries table and allocate far past the 16 MiB cap. Like memory, this is a fixed limit applied identically on every node, so a module either instantiates everywhere or nowhere. - What the guest hands back is validated before the host acts on it. The
returned result blob’s pointer and length are checked against the guest’s own
linear memory before the host allocates a buffer for it, and the blob’s
nesting is depth-bounded (
PATINADB_WASM_MAX_DECODE_DEPTH, default 256) because the host-side decoder runs after the guest call has returned — where fuel and the wall-clock deadline no longer apply. Either violation is a clean procedure error. - Strict IEEE-754 floating point, with no relaxed-SIMD and NaN canonicalization enabled, so floating-point results are bit-identical across platforms too.
A guest that traps (fuel exhausted, memory cap hit, deadline exceeded, out-of-bounds access, or an explicit guest panic) surfaces as a clean query error — never a process crash or a hang.
Because of this sandboxing, patinadb-wasm is an isolated, opt-in crate:
core patinadb has no dependency on it and pulls in no WebAssembly runtime by
default. A server or embedding application opts in explicitly (see below); a
plain build of the core library is unaffected either way.
Running on the server (replication & RBAC)
CREATE/DROP FUNCTION are DDL, and on a clustered server they behave like
any other schema change:
- Requires the global
adminrole.CREATE FUNCTIONreads a file off the leader’s local disk, so it’s authorized likeLOAD CSVand the CSV export procedures — by effect, not by role level. A per-database Writer is refused; only a global Admin can register or drop a function. The file path is confined by the same--allow-csv-dirsandboxLOAD CSVuses (see Cypher-driven file I/O). - Replicated by value, not by path. The leader reads, validates, and compiles the module exactly once, then proposes the resulting definition — module bytes included — as a single Raft log entry. Every node applies it identically: persists the definition and, if it has WASM support installed (see below), activates it. A node without WASM support still stores the definition (so it stays consistent and forwards writes correctly) but can’t execute the function locally.
- Snapshot-carried. A node that bootstraps purely from a streamed Raft
snapshot — with the original
CREATE FUNCTIONlog entry long since purged — still ends up with every registered function, because the snapshot itself carries the compiled module bytes. - Module-size cap. A registered module is capped at
PATINADB_MAX_WASM_MODULE_BYTES(default 8 MiB) so a large module can’t blow past the Raft peer-RPC body-size limit.CREATE FUNCTIONfails loudly, before anything is proposed, if the module is over the cap.
A server enables this feature with one line at startup
(patinadb_wasm::install(), already wired into the shipped patinadb-raft
binary) plus a build-time dependency on the patinadb-wasm crate — core
patinadb remains WASM-free either way.
The ABI (for guest authors)
A guest module must export:
memory— its linear memory.alloc(len: i32) -> i32— allocatelenbytes and return the pointer. Any allocation strategy works (the reference guest above uses a trivial bump allocator); the host calls this to place argument bytes into the guest’s memory before invoking the entry function, and the guest calls it itself to place its result.- The entry function named in
EXPORT '<name>', with signature(in_ptr: i32, in_len: i32) -> i64. The return value packs a pointer and a length into one 64-bit integer:(out_ptr as u64) << 32 | out_len as u64.
Wire format — a small, frozen, tag-prefixed binary encoding, deliberately not patinaDB’s internal storage format, so it stays a stable, language-agnostic contract for any guest toolchain:
-
The argument blob at
in_ptrisu32 count(little-endian) followed bycounttag-prefixed values. -
The result blob the guest allocates and returns is exactly one tag-prefixed value.
-
Each tagged value is one byte for the tag, then the payload:
Tag Type Payload 0Null (none) 1Integer i64, little-endian2Float f64bits, little-endian3Bool one byte, 0/14String u32length + UTF-8 bytes5List u32count + that many tagged values6Uuid 16 raw bytes (a node/relationship handle) 7Map u32count + that many(u32 key-len + key bytes + tagged value)entries8Node a Uuid + a labels-List + a properties-Map 9Relationship a start Uuid + an end Uuid + a type-String + a properties-Map
A Cypher value outside this set — a temporal value, a spatial Point/Polygon,
or a multi-hop Path — is rejected loudly as an unsupported argument/result
type rather than silently coerced.
Limitations
Calling from an application backend
Requires 0.21.0: correlated procedure calls after WITH preserve the
preceding row bindings. The published 0.20.0 can lose those bindings in this
shape. The application example below requires the seeded-procedure fix;
it is not evidence that the old release supports it.
Register the module through a separate administrator connection. The backend’s technical user does not need administrator rights to call a registered read-only procedure. Do not accept a module path, arbitrary procedure name or Cypher text from an application request.
For a paginated endpoint, select and authorize the nodes before invoking the
guest, then explicitly order the returned rows. For example, with $projects
derived from the authenticated user’s backend ACL rather than client input:
MATCH (ticket:Ticket)
WHERE ticket.project IN $projects
WITH ticket ORDER BY ticket.created_at DESC, ticket.issue_key ASC
SKIP $skip LIMIT $limit
CALL wasm.readSummary(ticket) YIELD result
RETURN ticket.issue_key AS key, result AS summary
ORDER BY ticket.created_at DESC, ticket.issue_key ASC
The operator supplies readSummary; it is not a built-in function. Its guest
can call node_get_property with the property name summary and forward the
packed result. Graph-reading determinism requires the same graph snapshot
as well as the same module and arguments. A later call after a committed update
can return a different value. Use the normal causal bookmark contract when a
reader must observe a preceding write; WASM does not provide a separate
replication barrier.
The guest’s graph access does not implement your application’s custom ACLs. Only install trusted modules whose reads respect the backend’s authorization boundary. A guest that traverses from an authorized node to arbitrary other nodes needs a separate authorization design; filtering its initial argument alone does not establish that all traversed data is authorized.
During rollout, keep guest traffic in a separately measured workload lane. Stopping that lane after errors may preserve other customer traffic, but is not a successful WASM qualification. Retain errors and distinguish unexpected query failures from intentionally tested sandbox traps.
Engine limits
- Read-only. A WASM procedure cannot create, update, or delete graph data.
- Not a scalar UDF (yet). A WASM function is a procedure, called with
CALL wasm.<name>(...) YIELD result— it does not (yet) compose into an arbitrary expression position likeRETURN f(n.x)or aWHEREclause. - No filesystem, network, clock, or randomness inside the guest, ever — this is the determinism guarantee, not a configurable option.
- The scalar/entity ABI subset above is everything. Temporal values,
spatial
Point/Polygon, and multi-hop paths are not (yet) marshalled across the boundary. - Graph reads are single-vertex point-reads and bounded neighbour lists — there’s no general traversal or query-execution host call from inside a guest.
CREATE EDGE SORTED INDEX-style module toolchains aren’t required or assumed — you bring your own WASM compiler; patinaDB only defines the ABI a compiled module must satisfy.
The full design write-up — including the alternatives considered and the
increment-by-increment implementation history — lives in the repository’s
docs/rfcs/0017-wasm-embedded-procedures.md.