Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 as CALL 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 wasm is 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 a file:// URL. The module’s bytes are read once, at CREATE FUNCTION time, 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 FUNCTION time 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 functionSignatureReturns
node_get_property(id_ptr, prop_ptr, prop_len) -> i64The property’s value (Null if absent).
node_labels(id_ptr) -> i64A List of the node’s labels as strings.
node_neighbors(id_ptr, dir, rel_ptr, rel_len) -> i64A 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_indirect dispatch 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 admin role. CREATE FUNCTION reads a file off the leader’s local disk, so it’s authorized like LOAD CSV and 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-dir sandbox LOAD CSV uses (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 FUNCTION log 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 FUNCTION fails 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 — allocate len bytes 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_ptr is u32 count (little-endian) followed by count tag-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:

    TagTypePayload
    0Null(none)
    1Integeri64, little-endian
    2Floatf64 bits, little-endian
    3Boolone byte, 0/1
    4Stringu32 length + UTF-8 bytes
    5Listu32 count + that many tagged values
    6Uuid16 raw bytes (a node/relationship handle)
    7Mapu32 count + that many (u32 key-len + key bytes + tagged value) entries
    8Nodea Uuid + a labels-List + a properties-Map
    9Relationshipa 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 like RETURN f(n.x) or a WHERE clause.
  • 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.