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.

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

Everything above rests on one guarantee: the same module given the same input produces the same output, everywhere, every time. patinaDB enforces this at the engine level, not by convention:

  • 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.
  • 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

  • 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.