Skip to content

Latest commit

 

History

History
1662 lines (1271 loc) · 71.4 KB

File metadata and controls

1662 lines (1271 loc) · 71.4 KB

WASM Deployment Guide

Overview

PlexSpaces supports deploying WebAssembly (WASM) applications from multiple languages (Rust, Python, TypeScript/JavaScript, Go) via HTTP multipart upload or gRPC, following industry best practices for large file uploads.

📖 For comprehensive polyglot development guide covering all languages, WIT abstractions, and examples, see Polyglot WASM Development Guide

Native Rust actors (embedded in the node, not compiled to WASM) use the Rust SDK: annotations such as #[gen_server_actor], #[handler], #[plexspaces_handlers], plus SDK spawn helpers like spawn, spawn_with_facets, and spawn_with_storage. For Rust WASM deployment, use the same SDK macro family in WASM mode (for example #[gen_server_actor(wasm)] and #[plexspaces_handlers(wasm)]) so the SDK generates the framework-owned WIT bindings instead of hand-written exports.

Quick Start: See DEPLOY_EMPTY_NODE_GUIDE.md for a complete workflow showing how to start an empty node, deploy a WASM application, and verify deployment via the dashboard.

Architecture

Node-Local Application Metrics

Deployed WASM applications report node-local benchmark and runtime counters through ApplicationMetrics. This is the source of truth for application-level summaries returned by GetApplicationStatus.

ApplicationMetrics is intentionally extensible:

  • actor_counts: counts by role/type on the current node
  • message_count, error_count: node-local totals
  • counter_metrics: application-defined counters such as scatter_gather_rounds or tuple_operations
  • latency_totals_ms, latency_max_ms, latency_samples: raw latency aggregates keyed by metric type such as worker.compute, worker.coordination, or leader

For multi-node WASM examples such as heat_diffusion, each node records its own metrics locally and the leader aggregates them by calling application-get-status on all participating nodes. This avoids any shared-database assumption and keeps per-node accounting aligned with deployment topology.

Namespace (Required)

Namespace is now required for all WASM deployments. It scopes all actors within an application and is used to construct actor IDs.

  • If not explicitly specified in the TOML config or API request, the namespace defaults to the application name.
  • Actor IDs use the canonical format: name//actor_type::namespace@node_id

Example: An application named my-app deployed to node node-1 without an explicit namespace will have actors with IDs like my-app//my-app::my-app@node-1.

Specifying in TOML config:

name = "my-app"
version = "1.0.0"
namespace = "my-app"  # Required: all actors scoped to this namespace

Specifying in API request (form field):

curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=my-app" \
  -F "name=my-app" \
  -F "namespace=my-app" \
  -F "version=1.0.0" \
  -F "wasm_file=@my_actor.wasm"

WASM Dependencies Verification

✅ WASM actors only use SDK/WIT-safe APIs - they do NOT embed host runtime services or hand-written ABI glue:

  • SDK-generated WIT bindings: Actors call framework services through SDK wrappers generated from WIT
  • Standard Library: Actors can use their language's standard library (e.g., Python's json, Rust's std)
  • No Host Runtime Dependencies: WASM modules do NOT embed plexspaces-node, Tokio runtimes, or direct gRPC clients - the framework is provided by the host runtime

Example (Python Actor):

# ✅ Correct - only uses standard library and WIT
import json  # Standard library

def handle_request(from_actor: str, message_type: str, payload: bytes) -> bytes:
    # Uses WIT host functions (provided by runtime)
    # host.send_message(...)  # WIT import
    # host.tuplespace_write(...)  # WIT import
    
    # Uses standard library
    request = json.loads(payload.decode('utf-8'))
    return json.dumps({'result': 42}).encode('utf-8')

Example (Rust Actor):

use plexspaces_sdk::{
    gen_server_actor, handler, json, plexspaces_handlers, ActorContext, BehaviorError, Message,
    Value,
};

#[gen_server_actor(wasm)]
struct CounterActor {
    value: i64,
}

#[plexspaces_handlers(wasm)]
impl CounterActor {
    #[handler("get")]
    async fn get(&mut self, _ctx: &ActorContext, _msg: &Message) -> Result<Value, BehaviorError> {
        Ok(json!({ "value": self.value }))
    }
}

File Size Considerations

Python-compiled WASM files are large (30-40MB) because:

  • componentize-py bundles the entire Python runtime
  • This is expected and normal for Python-to-WASM compilation
  • The runtime is shared across all Python actors on a node

Size Comparison by Language:

Language WASM Size Runtime Size Use Case
Rust 100KB-1MB Minimal Production, performance-critical
Go 2-5MB Small Good balance
JavaScript/TypeScript 500KB-2MB Medium Web integration
Python 30-40MB Large Rapid prototyping, ML

Size Reduction Options:

  1. Use wasm-opt (recommended):

    wasm-opt -Oz --strip-debug calculator_actor.wasm -o calculator_actor_opt.wasm
    # Typically reduces size by 20-40%
  2. Use Rust/Go/JavaScript instead of Python for smaller WASM files

  3. Optimize Python code:

    • Remove unused imports
    • Use minimal dependencies
    • Consider PyPy for smaller runtime (if supported)

Deployment Methods

Method 1: HTTP Multipart Upload (Recommended for Large Files)

Best Practice: Use HTTP multipart/form-data for large file uploads (>5MB), similar to document uploads in production applications.

Endpoint: POST http://localhost:8000/api/v1/applications/deploy

Content-Type: multipart/form-data

Body Size Limit: 100MB (configured via DefaultBodyLimit middleware in Axum)

Fields:

  • application_id (required): Unique application identifier (for tracking/debugging)
  • wasm_file (required): WASM module file (multipart file upload)
  • config (optional): ApplicationSpec TOML configuration file
  • name (required): Human-readable application name
  • version (required): Application version (e.g., "1.0.0")

Resource Limits:

  • Fuel limits are configured via WasmConfig.limits.max_fuel (default: 10 billion units)
  • For operations requiring heavy JSON serialization or complex computations, increase fuel limits
  • Fuel is consumed during execution (ops, memory access, calls)
  • Zero = unlimited (not recommended for untrusted code)
  • name (required): Human-readable application name
  • version (required): Application version (e.g., "1.0.0")
  • behavior_kind (optional): OTP-style behavior for logging (e.g. GenEvent for event-handler actors; logs show EventHandler)
  • wasm_file (required): WASM file (multipart file upload, max 100MB)
  • config (optional): Application config TOML file (if not provided, ApplicationSpec is auto-generated)

ApplicationSpec Auto-Generation: If config is not provided, the HTTP handler automatically creates an ApplicationSpec from form fields:

  • name: Set to application_id so runtime identity, application namespace, and undeploy all use the same key
  • namespace: Set to application_id so actor registration and dashboard queries use the same canonical scope
  • version: From version form field
  • type: ApplicationTypeActive (active application with processes)
  • description: Auto-generated as "WASM application: {name}"
  • dependencies: Empty array
  • env: Empty map (can be set via config TOML)
  • supervisor: None (can be set via config TOML)

ApplicationSpec Usage:

  • The ApplicationSpec is passed to WasmApplication::new() which implements the Application trait
  • Used for supervisor tree initialization (if specified in config)
  • Used for environment variables (if specified in config)
  • Follows the Erlang-style application model where applications are the unit of deployment
  • Matches the pattern used by the wasm-calculator example, ensuring consistent application deployment

ApplicationManager stores the deployed application under application_id, and object-registry uses that same identifier for registration and cleanup.

Response:

{
  "success": true,
  "application_id": "calculator-app",
  "status": "APPLICATION_STATUS_RUNNING",
  "error": null
}

Examples:

Python WASM (Calculator) - 39MB:

# CLI automatically uses HTTP for large files (>5MB)
./target/release/plexspaces deploy \
  --node localhost:8000 \
  --app-id calculator-app \
  --name calculator \
  --version 1.0.0 \
  --wasm examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm

# Or use HTTP directly
curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=calculator-app" \
  -F "name=calculator" \
  -F "version=1.0.0" \
  -F "wasm_file=@examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm"

Rust WASM - Small (<5MB):

# Build Rust WASM (e.g. from nbody_wasm wasm-actors or your crate)
cd examples/rust/embedded/nbody_wasm/wasm-actors
cargo build --target wasm32-wasip2 --release

# CLI uses gRPC for small files
./target/release/plexspaces deploy \
  --node localhost:8000 \
  --app-id rust-app \
  --name rust-actor \
  --wasm target/wasm32-wasip2/release/rust_actor.wasm

# Or use HTTP
curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=rust-app" \
  -F "name=rust-actor" \
  -F "version=1.0.0" \
  -F "wasm_file=@target/wasm32-wasip2/release/rust_actor.wasm"

TypeScript WASM (actor-world WIT, recommended):

Use the TypeScript SDK: extend PlexSpacesActor, bundle with esbuild, then build with jco:

# From examples/typescript/apps/bank_account
npm run build          # tsc + esbuild bundle
jco componentize account_actor_bundle.mjs --wit wit/plexspaces-actor -o account_actor.wasm --disable all

# Deploy via HTTP
curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=bank-test-ts" \
  -F "name=account" \
  -F "version=1.0.0" \
  -F "wasm_file=@account_actor.wasm" \
  -F "config=@app-config.toml"

Why HTTP over gRPC?

  • Industry Standard: HTTP multipart is the standard for file uploads (S3, GitHub, Docker)
  • No Size Limits: HTTP can handle files up to 100MB (configurable)
  • Better Tooling: Works with curl, wget, browsers, CDNs
  • No Global Impact: Doesn't require increasing gRPC message size limits for all APIs
  • Resumable: Can implement chunked/resumable uploads in the future

Method 2: CLI Tool (Automatic HTTP Fallback)

Note: CLI automatically detects file size and uses the appropriate method:

  • Files ≤5MB: Uses gRPC (faster, simpler)
  • Files >5MB and ≤100MB: Automatically uses HTTP multipart upload
  • Files >100MB: Returns error (optimize with wasm-opt first)

CLI Configuration:

  • gRPC max message size: 5MB (configured to match server)
  • HTTP max file size: 100MB (same as server limit)
  • Automatic fallback: CLI automatically switches to HTTP for large files

Command:

./target/release/plexspaces deploy \
  --node localhost:8000 \
  --app-id calculator-app \
  --name calculator \
  --version 1.0.0 \
  --wasm examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm

Works for: Small WASM files (<5MB), typically Rust or optimized JavaScript/Go

Examples:

Rust WASM (Small):

# Rust WASM files are typically <1MB, so CLI uses gRPC
./target/release/plexspaces deploy \
  --node localhost:8000 \
  --app-id rust-counter \
  --name counter \
  --wasm target/wasm32-wasip2/release/counter.wasm

Optimized JavaScript:

# After wasm-opt optimization, JavaScript WASM can be <2MB
wasm-opt -Oz --strip-debug greeter.wasm -o greeter_opt.wasm
# CLI uses gRPC for small files
./target/release/plexspaces deploy \
  --node localhost:8000 \
  --app-id greeter-app \
  --name greeter \
  --wasm greeter_opt.wasm

Large Python WASM (39MB):

# CLI automatically detects large file and uses HTTP multipart
./target/release/plexspaces deploy \
  --node localhost:8000 \
  --app-id calculator-app \
  --name calculator \
  --wasm examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm
# Output: "⚠️  WASM file size (39.00MB) exceeds gRPC limit (5MB), using HTTP multipart upload"

Method 3: Undeploy Application

Undeploy uses application_id. The same identifier used during deployment is also used for runtime state and object-registry cleanup.

HTTP DELETE:

# Use the canonical application_id
curl -X DELETE http://localhost:8000/api/v1/applications/calculator-app

CLI:

cargo run --release --bin plexspaces -- application undeploy \
  --node localhost:8000 \
  --name calculator-app

Identity Model

  • application_id: Canonical runtime identity for deploy, undeploy, namespace derivation, and object-registry registration
  • name: Human-readable label supplied by the client

Response:

{
  "success": true,
  "application_id": "calculator-app"
}

Polyglot Examples

Python Calculator Actor

Location: examples/simple/wasm_calculator/

Build:

cd examples/simple/wasm_calculator
./scripts/build_python_actors.sh

Deploy:

# HTTP (recommended for 39MB Python WASM)
curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=calculator-app" \
  -F "name=calculator" \
  -F "version=1.0.0" \
  -F "wasm_file=@wasm-modules/calculator_actor.wasm"

Undeploy:

# Use application_id (not name) for undeployment
curl -X DELETE http://localhost:8000/api/v1/applications/calculator-app

Rust Actor

Location: examples/rust/embedded/nbody_wasm/wasm-actors/ (or any crate with wasm32-wasip2 target)

Build:

cd examples/rust/embedded/nbody_wasm/wasm-actors
cargo build --target wasm32-wasip2 --release

Deploy:

# CLI (works for small Rust WASM)
./target/release/plexspaces deploy \
  --node localhost:8000 \
  --app-id rust-app \
  --name rust-actor \
  --wasm target/wasm32-wasip2/release/rust_actor.wasm

# Or HTTP
curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=rust-app" \
  -F "name=rust-actor" \
  -F "version=1.0.0" \
  -F "wasm_file=@target/wasm32-wasip2/release/rust_actor.wasm"

TypeScript/JavaScript Actor

Location: examples/typescript/apps/bank_account/

Uses the TypeScript SDK and the same plexspaces-actor WIT as Python. Build with jco (not Javy) so the component imports only plexspaces:actor/host.

Key Features:

  • SDK Handles WIT Types: WIT TypeScript types are generated automatically by the SDK - clients don't need to generate or import them
  • Proto-first ABI: Actor-world exchanges protobuf wire bytes and typed WIT results instead of JSON payload strings
  • Generated Models: SDKs encode/decode generated protobuf models at the boundary and keep business logic in the framework crates

Build:

cd examples/typescript/apps/bank_account
./scripts/build.sh     # tsc → esbuild bundle → jco componentize --disable all

Deploy:

curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=bank-test-ts" \
  -F "name=account" \
  -F "version=1.0.0" \
  -F "wasm_file=@account_actor.wasm" \
  -F "config=@app-config.toml"

Go Actor

Location: Use tinygo with wasip2 target. See examples/go/apps/ for Go WASM examples (e.g., examples/go/apps/migrating_gosiris/).

Build:

tinygo build -target=wasip2 -o go_actor.wasm go_actor.go

Deploy:

# HTTP (recommended)
curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=go-app" \
  -F "name=go-actor" \
  -F "version=1.0.0" \
  -F "wasm_file=@go_actor.wasm"

API Endpoints

HTTP Multipart Upload

Endpoint: POST /api/v1/applications/deploy

Port: gRPC and HTTP share a single port (e.g., 8000 by default)

Max File Size: 100MB

Example:

curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=calculator-app" \
  -F "name=calculator" \
  -F "version=1.0.0" \
  -F "wasm_file=@calculator_actor.wasm" \
  -F "config=@config.toml"

HTTP Timeout Query Parameter

For long-running operations (e.g., actors performing heavy computation or large data processing), you can specify a timeout in seconds using the ?timeout= query parameter. This overrides the default request timeout and keeps the connection open for the specified duration.

Usage: Append ?timeout=<seconds> to any actor API endpoint.

Example:

# Wait up to 30 seconds for the trainer actor to respond
curl "http://localhost:7993/api/v1/actors/my-app/trainer?timeout=30"

Without the timeout parameter, the default HTTP timeout applies. Use this when interacting with actors that perform long-running tasks such as model training, batch processing, or complex simulations.

HTTP Undeploy

Endpoint: DELETE /api/v1/applications/{application_id}

Important: Use application_id for undeployment and dashboard correlation.

Example:

# Use application_id
curl -X DELETE http://localhost:8000/api/v1/applications/calculator-app

gRPC / CLI

Endpoint: plexspaces.application.v1.ApplicationService/DeployApplication

gRPC Message Size Limit: 5MB (configured on both server and CLI client)

CLI Support:

  • Files ≤5MB: CLI uses gRPC (fast, efficient)
  • Files >5MB and ≤100MB: CLI automatically uses HTTP multipart (seamless fallback)
  • Files >100MB: CLI returns error with optimization suggestion

CLI Configuration:

  • gRPC max message size: 5MB (matches server)
  • HTTP max file size: 100MB (matches server)
  • Automatic detection: CLI checks file size and chooses appropriate method

Example (using grpcurl):

grpcurl -plaintext \
  -d '{
    "application_id": "rust-app",
    "name": "rust-actor",
    "version": "1.0.0",
    "wasm_module": {
      "name": "rust-actor",
      "version": "1.0.0",
      "module_bytes": "'$(base64 -i rust_actor.wasm)'"
    }
  }' \
  localhost:8000 \
  plexspaces.application.v1.ApplicationService/DeployApplication

Size Limits

gRPC Message Size

  • Default: 4MB (gRPC default)
  • PlexSpaces Setting: 5MB (configured for flexibility)
  • Use Case: Small WASM files, metadata-only deployments

HTTP Multipart Upload

  • Max WASM File Size: 100MB (enforced by server)
  • Use Case: Large WASM files (Python, unoptimized builds)

Recommendations

  • Files <5MB: CLI automatically uses gRPC (faster, simpler)
  • Files 5-100MB: CLI automatically uses HTTP multipart (seamless)
  • Files >100MB: Optimize with wasm-opt first, then deploy (CLI will use HTTP)

CLI Behavior:

  • Automatically detects file size
  • Uses gRPC for files ≤5MB
  • Automatically switches to HTTP for files >5MB and ≤100MB
  • Returns error for files >100MB (with suggestion to optimize)

Optimization Recommendations

1. Use wasm-opt (Binaryen)

# Install
brew install binaryen  # macOS
apt-get install binaryen  # Linux

# Optimize WASM file
wasm-opt -Oz --strip-debug calculator_actor.wasm -o calculator_actor_opt.wasm

# Check size reduction
ls -lh calculator_actor*.wasm

Expected Results:

  • Python WASM: 39MB → 25-30MB (20-30% reduction)
  • Rust WASM: 1MB → 500KB (50% reduction)
  • JavaScript WASM: 2MB → 1MB (50% reduction)

2. Build Script Integration

The build script (build_python_actors.sh) automatically optimizes WASM files if wasm-opt is available:

./examples/simple/wasm_calculator/scripts/build_python_actors.sh
# Automatically runs wasm-opt if available

3. Language Selection for Production

For production deployments, consider language choice:

Language WASM Size Build Time Runtime Performance Use Case
Rust 100KB-1MB Medium Excellent Production, performance-critical
Go 2-5MB Fast Good Good balance, fast iteration
JavaScript 500KB-2MB Fast Good Web integration, rapid prototyping
Python 30-40MB Medium Moderate ML, data processing, rapid prototyping

Performance, Concurrency, and Scalability

PlexSpaces uses the WASM Component Model (latest and recommended): WIT-based components, wasmtime with component-model support, and polyglot actors (Python, Rust, TypeScript, Go). This section summarizes performance characteristics and how to get the most out of the system for highly performant deployments.

Component Model vs Traditional Modules

Path Cost per message When to use
Component model (SimpleActor, PlexspacesActor — Python, WIT) Per-message re-instantiation (new Store + instance per handle). Component is not recompiled; instantiation + init per message. Polyglot (Python, etc.), WIT interfaces, latest tooling. Recommended for most apps.
Traditional WASM modules (non-component) One instantiation per actor lifetime; same Store/instance reused. Maximum throughput per actor, hot paths, Rust/Go JS without WIT.

The runtime replaces component state after each successful handle() for component-model actors to avoid wasmtime’s “cannot enter component instance” trap on the second call. That keeps behavior correct and allows multiple sequential messages per actor.

Concurrency and Scalability

  • Per-actor locking: One lock per WASM instance (one message at a time per actor). This is the normal actor model.
  • Across actors: Different actors use different instances and locks. Many actors can handle messages concurrently; there is no global lock.
  • Horizontal scaling: Adding more actors increases parallelism. Re-instantiation is per-actor, so scaling out (more actors, more nodes) scales well.
  • Vertical scaling (messages/sec per actor): For component-model actors, per-message re-instantiation is the main limit. For very high single-actor throughput, use traditional WASM modules or offload hot work to them.

Performance Tips

  1. Engine-level pooling (default: on)

    • Enabled by default: The node creates the WASM runtime with WasmRuntime::new(), which uses WasmConfig::default() where enable_pooling = true.
    • This turns on wasmtime’s pooling allocator (InstanceAllocationStrategy::Pooling): the engine reuses memory and instance allocations instead of allocating per instantiation.
    • You get this automatically when starting a node; no extra config is required.
    • To turn it off (e.g. for debugging), create the runtime with WasmRuntime::with_config(config) and set config.enable_pooling = false.
  2. Instance pooling (recommended, default: on)

    • Recommendation: Yes. Instance pooling (pre-instantiated instances you checkout instead of instantiating each time) reduces spawn latency when many actors of the same module are created.
    • Config: use_instance_pool in WasmConfig (and in proto). On by default (true). When true, the runtime may use a per-module InstancePool to serve instantiate requests (checkout from pool instead of full instantiation).
    • Current status: Deploy-path integration is planned. Until then, only engine-level pooling (above) is active when creating actors via HTTP/deploy; each actor is still created via runtime.instantiate(). The InstancePool type exists in plexspaces-wasm-runtime and can be used in custom code (e.g. high-spawn-rate workers). When deploy-path integration is complete, use_instance_pool = true will enable checkout-from-pool for spawns.
    • To turn instance pooling off: set config.use_instance_pool = false (or the proto field when using gRPC/config).
  3. Keep durability off unless needed

    • durability_enabled is off by default in WasmConfig (and in proto).
    • When off, no checkpoint load on init or save on terminate — no extra I/O or serialization.
    • Turn on only when actor state must survive restarts (e.g. Durable Objects–style apps).
  4. Scale horizontally

    • Run more actors and/or more nodes to increase throughput.
    • Component-model cost is per-actor; spreading load across actors avoids a single-actor bottleneck.
  5. Use wasm-opt

  6. Prefer smaller, focused actors

    • Many small actors can outperform fewer “heavy” actors by better utilizing concurrency and pooling.
  7. Hot path: traditional WASM

    • For a few actors that must handle very high message rates, use traditional WASM modules (non-component) so the same Store/instance is reused and there is no per-message re-instantiation.
  8. Resource limits

    • Set limits in WasmConfig (e.g. max_memory_bytes, max_fuel) to avoid runaway usage; tighter limits can also improve predictability.
    • Fuel limits: Default is 10 billion units (~1 second CPU time). For operations requiring heavy JSON serialization or complex computations, increase max_fuel (e.g., u64::MAX / 2 for very large operations). Fuel is consumed during execution (ops, memory access, calls). Zero = unlimited (not recommended for untrusted code).

Summary

  • Performance: Component model is correct and recommended; it pays a per-message instantiation cost. Traditional modules give the highest per-actor throughput.
  • Concurrency: Good — per-actor serialization, no global serialization; many actors run in parallel.
  • Scalability: Good horizontally (more actors/nodes); per-actor throughput is the main limit for component-model.
  • For high performance: Use pooling, keep durability off by default, scale out with more actors, optimize with wasm-opt, and use traditional WASM on the hottest paths if needed.

Complete Deployment Workflow

1. Build WASM Module

Python:

cd examples/simple/wasm_calculator
./scripts/build_python_actors.sh
# Output: wasm-modules/calculator_actor.wasm

Rust:

cd examples/rust/embedded/nbody_wasm/wasm-actors
cargo build --target wasm32-wasip2 --release
# Output: target/wasm32-wasip2/release/*.wasm

TypeScript (actor-world WIT):

cd examples/typescript/apps/bank_account
./scripts/build.sh   # tsc → esbuild bundle → jco componentize --disable all
# Output: account_actor.wasm

2. Start Empty Node

# Start an empty node using CLI
cargo run --release --bin plexspaces -- start \
  --node-id test-node \
  --listen-addr 0.0.0.0:8000

Note:

  • gRPC and HTTP share a single port; dashboard available at http://localhost:8000/
  • You can check dashboard stats before deployment (should show 0 applications)

Verify Node is Running:

# Check dashboard summary
curl http://localhost:8000/api/v1/dashboard/summary | jq '.total_applications'
# Should return: 0

3. Deploy Application

ApplicationSpec Creation: The HTTP handler automatically creates an ApplicationSpec from form fields when deploying WASM applications. This follows the Erlang-style application model where applications are the unit of deployment.

How ApplicationSpec is Created:

  1. If config field is provided (TOML), it's parsed into ApplicationSpec
  2. If config is not provided, ApplicationSpec is auto-generated from form fields:
    • name: From name form field → Used as application identifier in ApplicationManager (important for undeployment)
    • version: From version form field
    • type: ApplicationTypeActive (active application with processes)
    • description: Auto-generated as "WASM application: {name}"
    • dependencies: Empty array
    • env: Empty map (can be set via config TOML)
    • supervisor: None (can be set via config TOML)

ApplicationSpec Usage:

  • The ApplicationSpec is passed to WasmApplication::new() which implements the Application trait
  • Used for supervisor tree initialization (if specified)
  • Used for environment variables (if specified)
  • Follows the same pattern as the wasm-calculator example

Deployment via HTTP (Recommended for Large Files >5MB):

# Deploy with auto-generated ApplicationSpec
curl -v -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=calculator-app" \
  -F "name=calculator" \
  -F "version=1.0.0" \
  -F "wasm_file=@wasm-modules/calculator_actor.wasm"

# Deploy with custom ApplicationSpec (via config TOML)
curl -v -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=calculator-app" \
  -F "name=calculator" \
  -F "version=1.0.0" \
  -F "wasm_file=@wasm-modules/calculator_actor.wasm" \
  -F "config=@app-config.toml"

Deployment via CLI (For Small Files <5MB):

cargo run --release --bin plexspaces -- application deploy \
  --node localhost:8000 \
  --app-id rust-app \
  --name rust-actor \
  --version 1.0.0 \
  --wasm target/wasm32-wasip2/release/rust_actor.wasm

⚠️ Critical Notes:

  • Application Name vs Application ID: The name field is used by ApplicationManager for storage and lookup. Use the application_id (not name) when undeploying — it is the unique key used by the HTTP DELETE endpoint and ApplicationManager.
  • WASM Components (Python, TypeScript): ✅ Fully Supported - Components built with componentize-py (Python) or jco componentize (TypeScript) use the actor-world WIT interface from wit/plexspaces-actor
    • Uses protobuf bytes plus typed WIT results for actor-world payloads and errors
    • TypeScript: build with jco componentize ... --disable all so the component only imports plexspaces:actor/host (no WASI)
    • See examples/python/ and examples/typescript/apps/bank_account/ for working examples
    • See wit/plexspaces-actor/ for the WIT interface
  • Traditional WASM Modules (Rust, Go): ✅ Supported - Use standard actor interface
  • ApplicationSpec is Required: All WASM deployments must include an ApplicationSpec (auto-generated or provided). This ensures applications follow the Erlang-style application model.

Testing WASM Deployment:

  • The integration test (cargo test --package plexspaces-node --test http_wasm_deployment) creates a working traditional WASM module and successfully deploys it

Auto-Deploy and Persistence

Auto-Deploy on Startup

PlexSpaces automatically deploys WASM applications from the wasm_apps_directory on node startup. This enables Tomcat-style auto-deployment where applications persist across restarts.

File Structure:

{wasm_apps_directory}/
  payment-handler/
    app.wasm                     # Required: WASM module
    application-spec.toml        # Optional: ApplicationSpec config
  calculator/
    app.wasm
    application-spec.toml

Configuration:

  • Environment variable: PLEXSPACES_WASM_APPS_DIR (default: ${base_dir}/apps)
  • Config file: runtime.wasm_apps_directory in release.yaml

How It Works:

  1. Node scans wasm_apps_directory on startup
  2. Finds all subdirectories containing app.wasm files
  3. Automatically deploys each valid WASM application
  4. Errors are logged but don't prevent node startup

Saving WASM Files on API Deployment

When deploying via HTTP/gRPC API, you can optionally save WASM files to disk for persistence.

Configuration:

  • Environment variable: PLEXSPACES_SAVE_WASM_APPS=1 (default: disabled)
  • Config file: runtime.save_wasm_apps: true in release.yaml

Important:

  • ⚠️ Only saves during API deployments (HTTP/gRPC) - NOT during auto-deploy
  • ⚠️ Disabled by default - only enable for testing/development
  • ⚠️ Production: Use proper deployment pipelines
  • Files are saved atomically to prevent corruption
  • Format: {wasm_apps_directory}/{app-name}/app.wasm and {wasm_apps_directory}/{app-name}/application-spec.toml
  • If namespace is omitted from application-spec.toml, WASM deployment derives it from the app name so actor IDs and app isolation remain stable across deploy and restart.

Example Workflow:

# 1. Enable saving (testing only)
export PLEXSPACES_SAVE_WASM_APPS=1

# 2. Deploy via API - files are saved to apps/payment-handler/app.wasm and application-spec.toml
curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=payment-handler" \
  -F "name=payment-handler" \
  -F "version=1.0.0" \
  -F "wasm_file=@payment-handler.wasm"

# 3. On next restart, payment-handler is auto-deployed automatically from the subdirectory

See Installation Guide for complete details.

  • Use the test script (./scripts/test-empty-node-deployment.sh) which automatically creates a working WASM module
  • For manual testing, use Rust/Go WASM modules or TypeScript/Python components (actor-world WIT)

4. Verify Deployment

Check Dashboard Stats:

# Check dashboard summary (should show 1 application now)
curl http://localhost:8000/api/v1/dashboard/summary | jq '.total_applications'
# Should return: 1

List applications (gRPC; gRPC and HTTP share the same port, e.g. 8000):

cargo run --bin plexspaces -- list --node localhost:8000 --json

Use the same ListApplications RPC with grpcurl or other gRPC clients if you are not using the CLI.

View Dashboard:

# Open in browser
open http://localhost:8000/
# Or
http://localhost:8000/dashboard/node/test-node

Complete Dashboard Workflow:

  1. Start empty node → Check dashboard (0 applications)
  2. Deploy WASM application → Check dashboard (1 application)
  3. Undeploy application → Check dashboard (0 applications)

See DEPLOY_EMPTY_NODE_GUIDE.md for the complete workflow.

5. Undeploy Application

Important: Use application_id for undeployment and dashboard correlation.

HTTP:

# Use application_id
curl -X DELETE http://localhost:8000/api/v1/applications/calculator-app

CLI:

cargo run --release --bin plexspaces -- application undeploy \
  --node localhost:8000 \
  --name calculator

Verify Undeployment:

# Check dashboard (should show 0 applications again)
curl http://localhost:8000/api/v1/dashboard/summary | jq '.total_applications'
# Should return: 0

Troubleshooting

"Message length too large" Error

Problem: WASM file exceeds gRPC 5MB limit

Solution:

  • CLI: Automatically handles this - if you see this error, the CLI should have automatically switched to HTTP. Check CLI version.
  • Manual: Use HTTP multipart upload:
curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "wasm_file=@large_file.wasm" \
  ...

"Failed to parse multipart/form-data" Error

Problem: HTTP multipart parsing fails when uploading WASM files

Solution:

  • Server Configuration: The server is configured with a 100MB body size limit via DefaultBodyLimit middleware. If you see this error:
    1. Verify the file size is ≤100MB
    2. Check that the Content-Type header is multipart/form-data
    3. Ensure all required fields are present (application_id, name, version, wasm_file)
    4. Check server logs for detailed error messages

Example with proper curl syntax:

curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=calculator-app" \
  -F "name=calculator" \
  -F "version=1.0.0" \
  -F "wasm_file=@examples/simple/wasm_calculator/wasm-modules/calculator_actor.wasm"

"Payload too large" Error

Problem: WASM file exceeds HTTP 100MB limit

Solutions:

  1. Use wasm-opt to reduce size
  2. Consider Rust/Go for smaller files
  3. Split application into multiple smaller modules (future)

"PyObject_SetItem" Error (Python WASM Components)

Problem: Python WASM components crash during initialization with:

error while executing at wasm backtrace:
    ...
    libpython3.12.so!PyObject_SetItem
    libcomponentize_py_runtime.so!set_item::inner

Root Cause: componentize-py's pyo3 runtime tries to call os.putenv() during Python initialization. WASI doesn't support runtime environment variable modification, causing the crash.

Solution: The PlexSpaces runtime is configured to NOT inherit environment variables. Instead, it explicitly sets only the minimal env vars Python needs:

// In crates/wasm-runtime/src/instance.rs
let wasi_ctx = wasmtime_wasi::WasiCtxBuilder::new()
    .inherit_stdio()
    // Don't use inherit_env() - causes PyObject_SetItem errors
    .env("PYTHONDONTWRITEBYTECODE", "1")
    .env("PYTHONUNBUFFERED", "1")
    .env("HOME", "/")
    .env("PATH", "/")
    .build();

This fix is already applied in the codebase. If you're running an older version, update to the latest.

Python 3.14 WASM Memory Bugs (Critical)

Problem: Python WASM actors crash with memory deallocation errors:

error while executing at wasm backtrace:
    0: libpython3.14.so!tuple_dealloc
    1: libpython3.14.so!_Py_Dealloc
    ...

Error Types:

Error Symptom Cause
match_dealloc Crash when using pattern matching or hashlib hashlib.md5() and similar functions
tuple_dealloc Crash when returning from functions Complex return values, json.dumps()
func_dealloc Crash during function cleanup Helper function calls

Root Cause: The Python 3.14 runtime in componentize-py has memory management bugs in the WASM environment.

Workarounds:

# ❌ CRASHES - hashlib causes match_dealloc
import hashlib
h = hashlib.md5((flag + user).encode()).hexdigest()

# ✅ WORKS - simple inline hash
h = 0
for c in (flag + user):
    h = (h + ord(c)) % 100

# ❌ MAY CRASH - json.dumps with complex nested data
return json.dumps({"status": "ok", "data": {"nested": "value"}})

# ✅ WORKS - string literal for simple responses
return '{"status":"ok"}'

# ❌ MAY CRASH - helper function call
def my_hash(s):
    return sum(ord(c) for c in s) % 100
h = my_hash(flag + user)

# ✅ WORKS - inline the logic
h = 0
for c in (flag + user):
    h = (h + ord(c)) % 100

Best Practices for Stable Python WASM Actors:

  1. Avoid hashlib entirely - Use simple arithmetic hash functions
  2. Use string literals for simple JSON - '{"ok":true}' instead of json.dumps({"ok": True})
  3. Inline calculations - Don't extract logic into helper functions
  4. Flat control flow - Avoid nested try-except blocks
  5. Simple return values - Keep data structures flat and simple

Reference: See examples/python/apps/feature_flags/ for a working example with all workarounds applied.

WASM File Too Large

Problem: Python WASM files are 30-40MB

Solutions:

  1. Use wasm-opt to reduce size (20-30% reduction)
  2. Consider Rust/Go for smaller files
  3. Use HTTP multipart (supports up to 100MB)

Deployment Fails

Check:

  1. Node is running: curl http://localhost:8000/health or curl http://localhost:8000/health
  2. WASM file is valid: wasm-validate calculator_actor.wasm (if wasm-validate is installed)
  3. WIT interface matches: Check wit/plexspaces-actor/actor.wit
  4. HTTP gateway is running: Check logs for "Starting HTTP gateway server on http://..."

HTTP Gateway Not Accessible

Problem: Cannot connect to HTTP endpoint

Check:

  1. gRPC and HTTP share a single port — check node logs for "Single-port gRPC+HTTP server ready"
  2. Verify the port in --listen-addr matches your curl commands
  3. Verify firewall allows connections to HTTP port

Python WASM Development

Prerequisites

# Create Python 3.12+ virtual environment
python3.12 -m venv ~/venv
source ~/venv/bin/activate

# Install componentize-py
pip install componentize-py

WIT Interface (actor-world)

Python components use the actor-world WIT world located at wit/plexspaces-actor/world.wit. This interface uses protobuf wire bytes for complex request/response bodies and WIT result for failures. SDKs generate the protobuf models and hide the raw byte handling from app code:

package plexspaces:actor@0.1.0;

interface actor {
    init: func(config: payload) -> result<_, actor-error>;
    handle: func(from-actor: string, msg-type: string, payload: payload) -> result<payload, actor-error>;
    get-state: func() -> result<payload, actor-error>;
    set-state: func(state: payload) -> result<_, actor-error>;
}

// The host API is organized into 9 namespaced interfaces.
// Key interfaces shown here; see wit/plexspaces-actor/ for the complete surface.

interface host-actor {
    send: func(to: string, msg-type: string, payload: payload) -> result<_, actor-error>;
    ask: func(to: string, msg-type: string, payload: payload, timeout-ms: u64) -> result<payload, actor-error>;
    self-id: func() -> string;
    spawn: func(module-ref: string, actor-id: string, init-config: payload) -> result<string, actor-error>;
    stop: func(actor-id: string) -> result<_, actor-error>;
    send-after: func(delay-ms: u64, msg-type: string, payload: payload) -> result<string, actor-error>;
    // + link, unlink, monitor, demonitor, pg-join, pg-leave, pg-members, pg-broadcast
}

interface host-logging {
    log: func(level: string, message: string);
    now-ms: func() -> u64;
}

interface host-kv {
    kv-get: func(key: string) -> result<payload, actor-error>;
    kv-put: func(key: string, value: payload) -> result<_, actor-error>;
    kv-delete: func(key: string) -> result<_, actor-error>;
    kv-list: func(prefix: string) -> result<list<string>, actor-error>;
    kv-put-with-ttl: func(key: string, value: payload, ttl-seconds: u64) -> result<_, actor-error>;
    kv-get-ttl: func(key: string) -> result<u64, actor-error>;
    kv-cas: func(key: string, expected: payload, new-value: payload) -> result<bool, actor-error>;
    kv-increment: func(key: string, delta: s64) -> result<s64, actor-error>;
    kv-multi-get: func(keys-json: payload) -> result<payload, actor-error>;
    kv-multi-put: func(entries-json: payload) -> result<_, actor-error>;
    // Alarm functions are co-located in host-kv for implementation convenience.
    // Use host.alarm / host.Alarm() in SDK code for the clean alarm namespace API.
    alarm-set: func(timestamp-ms: u64) -> result<_, actor-error>;
    alarm-get: func() -> result<u64, actor-error>;
    alarm-delete: func() -> result<_, actor-error>;
}

interface host-ts {
    ts-write: func(request: payload) -> result<_, actor-error>;
    ts-read: func(request: payload) -> result<payload, actor-error>;
    ts-take: func(request: payload) -> result<payload, actor-error>;
    ts-read-all: func(request: payload) -> result<payload, actor-error>;
}

// Additional namespaced interfaces: host-locks, host-blob, host-pool, host-shard, host-http
// See wit/plexspaces-actor/ for complete definitions.

world actor-world {
    import host-actor;
    import host-logging;
    import host-kv;
    import host-ts;
    import host-locks;
    import host-blob;
    import host-pool;
    import host-shard;
    import host-http;
    export actor;
}

TupleSpace (host.ts / host-ts interface) for WASM

WASM actors using the actor-world WIT call host.ts.write (Python/TypeScript) or host.TS().Write (Go) with protobuf WriteRequest bytes and read/take using protobuf ReadRequest bytes. The runtime decodes those bytes once and delegates to the same TupleSpace backend as native code. Use this for event streams, audit logs, or coordination without keyvalue.

When to use host.ts.write: Prefer tuplespace writes for fire-and-forget event or audit streams when WASM integration is stable; it avoids reentrancy and readonly issues that can occur when WASM calls into the keyvalue backend during message handling.

Deprecated flat name: host.ts_write is the old flat API name; use host.ts.write / host.TS().Write instead.

Elastic pool (WASM host)

WASM actors using the actor-world WIT can use the elastic pool API to checkout workers from a named pool, send them work, and check them in when done. When the pool is not configured (or checkout fails), application code can fall back to process group broadcast.

WIT function Description
pool-checkout(pool-name, timeout-ms) Returns protobuf plexspaces.pool.v1.ActorHandle on success or typed actor-error on failure/timeout.
pool-checkin(pool-name, actor-id, checkout-id, healthy) Returns success/error result.
pool-get-metrics(pool-name) Returns protobuf plexspaces.pool.v1.PoolMetrics.

SDK usage: Python host.pool_checkout / host.pool_checkin / host.pool_get_metrics; Go host.PoolCheckout / host.PoolCheckin / host.PoolGetMetrics; TypeScript host.poolCheckout / host.poolCheckin / host.poolGetMetrics. See Parameter sweep (migrating_merlin) (Python, Go, TypeScript, Rust) for a full example combining pool, tuple space (work queue), and process group fallback.

ShardGroup scatter-gather (WASM host)

Deployable WASM apps can also use the framework shard-group APIs through the actor-world host. This keeps leader-worker apps on the same core ActorService path used by native Rust.

WIT function Description
create-shard-group(request) Uses protobuf CreateShardGroupRequest / CreateShardGroupResponse.
bulk-update-shard-group(request) Uses protobuf BulkUpdateShardGroupRequest / BulkUpdateShardGroupResponse.
map-shard-group(request) Uses protobuf MapShardGroupRequest / MapShardGroupResponse.
scatter-gather(request) Uses protobuf ScatterGatherRequest / ScatterGatherResponse.

Use this for WASM leader-worker applications that need framework-owned scatter/gather without dropping down to gRPC or hand-written host bindings. See Heat Diffusion for a Rust WASM example that deploys to multiple nodes and drives workers through the host shard-group surface.

Object Registry (WASM)

WASM actors can register, discover, and look up services through the object registry host surface. All registry WIT functions use protobuf wire-encoded payloads (list<u8>) so the data model stays proto-first across the WASM boundary. Message definitions live in proto/plexspaces/v1/registry/object_registry.proto.

WIT function Proto request type Proto response type Description
register(request) plexspaces.object_registry.v1.RegisterRequest (unit) Register this actor in the service registry.
unregister(request) plexspaces.object_registry.v1.UnregisterRequest (unit) Remove a registration.
lookup(request) plexspaces.object_registry.v1.LookupRequest plexspaces.object_registry.v1.LookupResponse Look up a single object by id and type.
lookup-by-alias(alias) (string) plexspaces.object_registry.v1.LookupResponse Resolve an alias string to a registered object.
discover(request) plexspaces.object_registry.v1.DiscoverRequest plexspaces.object_registry.v1.DiscoverResponse Filter-based discovery of multiple objects.
heartbeat(request) plexspaces.object_registry.v1.HeartbeatRequest (unit) Refresh the liveness timestamp of a registration.

Tenant isolation is enforced at the host layer — the actor cannot forge a different tenant:

  • tenant_id in the host context is always injected from the deployment (from the JWT at gRPC deploy time, or from tenant_id in app-config.toml for file-copy deploys). The guest-supplied tenant_id field inside the proto payload is always ignored.
  • namespace may be supplied by the guest; if empty the host falls back to the application's default namespace.

SDK usage (SDKs handle proto encoding/decoding):

# Python
host.registry_register({"object_id": actor_id, "object_type": "actor", "object_category": "worker"})
result = host.registry_discover({"object_type": "actor", "object_category": "worker"})
// Go
host.Registry().Register(plexspaces.ObjectRegistration{
    ObjectID: cfg.ActorID, ObjectType: "actor", ObjectCategory: "worker",
})
regs, _ := host.Registry().Discover(plexspaces.DiscoverOptions{
    ObjectType: plexspaces.ObjectType.ACTOR,
})
// TypeScript
host.registry.register({ objectId: actorId, objectType: "actor", objectCategory: "worker" });
const regs = host.registry.discover({ objectType: RegistryObjectType.ACTOR });

For file-copy / embedded deploys — set tenant_id in app-config.toml to enable tenant isolation:

name    = "my-app"
version = "1.0.0"
tenant_id = "acme"        # required for multi-tenant nodes
namespace = "production"

If tenant_id is omitted the node logs a warning and all registry calls use an empty tenant scope, which effectively disables isolation. See Auto-Deploy and Persistence for the full file-copy deploy workflow.

Key-Value Storage (WASM)

WASM actors using actor-world can persist data via the host keyvalue API. This avoids in-actor state serialization issues and provides reliable storage across the WASM boundary.

Choosing storage: For event streams or audit logs, prefer tuplespace writes with protobuf WriteRequest payloads. Key-value values are actor-world bytes, so SDKs typically persist protobuf messages or application-owned binary payloads without JSON adapters.

SDK namespace accessors: Python host.kv.*, Go host.KV().*, TypeScript host.kv.*.

WIT function (host-kv interface) SDK — Python SDK — Go SDK — TypeScript Description
kv-get(key) host.kv.get(key) host.KV().Get(key) host.kv.get(key) Returns raw value bytes on success, or a typed actor error.
kv-put(key, value) host.kv.put(key, value) host.KV().Put(key, value) host.kv.put(key, value) Stores raw value bytes. Returns success/error as a WIT result.
kv-put-with-ttl(key, value, ttl_seconds) host.kv.put_with_ttl(k,v,ttl) host.KV().PutWithTTL(k,v,ttl) host.kv.putWithTtl(k,v,ttl) Like kv-put but the key expires after ttl_seconds.
kv-get-ttl(key) host.kv.get_ttl(key) host.KV().GetTTL(key) host.kv.getTtl(key) Returns remaining TTL in seconds, or 0 if no TTL / key not found.
kv-delete(key) host.kv.delete(key) host.KV().Delete(key) host.kv.delete(key) Removes a key (idempotent).
kv-list(prefix) host.kv.list(prefix) host.KV().List(prefix) host.kv.list(prefix) Returns all keys matching prefix.
kv-cas(key, expected, new_value) host.kv.cas(key,exp,new) host.KV().CAS(key,exp,new) host.kv.cas(key,exp,new) Compare-and-swap: sets value only if current matches expected. Returns bool. Pass empty bytes for expected when key must not exist.
kv-increment(key, delta) host.kv.increment(key,n) host.KV().Increment(key,n) host.kv.increment(key,n) Atomically increments a numeric key by delta (creates key at delta if absent). Returns new value.
kv-multi-get(keys_json) host.kv.multi_get(keys) host.KV().MultiGet(keys) host.kv.multiGet(keys) Fetches multiple keys. Accepts JSON-encoded [string] array; returns JSON array of base64-encoded values or null for missing keys.
kv-multi-put(entries_json) host.kv.multi_put(entries) host.KV().MultiPut(entries) host.kv.multiPut(entries) Stores multiple key-value pairs. Accepts JSON object mapping key → base64-encoded value string.

Scope: Keys are scoped per actor (namespace derived from actor ID). The node provides an in-memory keyvalue store for WASM actors by default.

kv-multi-get / kv-multi-put encoding note: Because TinyGo cannot pass WIT list<string> as host-import inputs, both batch functions use payload (list) at the WIT boundary. The caller JSON-encodes the key list / entry map, and binary values are base64-encoded within the JSON.

Deprecated flat API: The old flat names (host.kv_get, host.kv_put, host.KVGet, host.KVPut, host.kvGet, etc.) remain for backward compatibility but are deprecated. Use the host.kv / host.KV() namespace accessor instead.

Example (Python SDK):

from plexspaces import actor, handler, host

@actor
class SensorStream:
    @handler("ingest")
    def ingest(self, sensor_id: str = "", value: str = "0") -> dict:
        raw = host.kv.get("readings")
        data = ReadingList().from_bytes(raw) if raw else ReadingList()
        data.items.append(Reading(sensor_id=sensor_id, value=value))
        host.kv.put("readings", data.to_bytes())
        return {"reading_count": len(data.items)}

    @handler("count")
    def count(self) -> dict:
        raw = host.kv.get("readings")
        data = ReadingList().from_bytes(raw) if raw else ReadingList()
        return {"reading_count": len(data.items)}

Best practice: Have handlers return protobuf-backed models or SDK-native values and let the SDK decorator own actor-world serialization. Keep business logic in handlers and keep the WIT boundary thin.

Durable Alarms (WASM)

WASM actors can schedule a single durable alarm per actor instance — equivalent to Cloudflare Durable Objects state.storage.setAlarm(timestamp). The alarm is backed by ReminderFacet and survives actor deactivation.

Operation WIT Function Go Python TypeScript
Set alarm alarm-set host.Alarm().Set(tsMs) host.alarm.set(ts_ms) host.alarm.set(tsMs)
Get alarm alarm-get host.Alarm().Get() host.alarm.get() host.alarm.get()
Delete alarm alarm-delete host.Alarm().Delete() host.alarm.delete() host.alarm.delete()

Descriptions:

  • alarm-set(timestamp_ms): Schedule the alarm to fire at timestamp_ms (Unix milliseconds). Replaces any existing alarm for this actor.
  • alarm-get(): Returns the scheduled alarm timestamp in ms, or 0 if no alarm is set.
  • alarm-delete(): Cancels the pending alarm (idempotent).

When the alarm fires, the runtime delivers a "__alarm__" message to the actor's handle() function. Actors handle it like any other message type:

# Python SDK
@handler("__alarm__")
def on_alarm(self) -> dict:
    # process batch, flush buffer, etc.
    return {}
// TypeScript SDK
@handler("__alarm__")
async onAlarm(payload: any) {
    // process batch
    return {};
}
// Go SDK — switch on message type in your handler
case "__alarm__":
    // process batch

Replacing an alarm: Calling alarm-set while an alarm is already pending atomically replaces it with the new timestamp. The old pending delivery is cancelled.

Cloudflare equivalence: alarm-set(Date.now() + 10_000) + case "__alarm__" maps directly to Cloudflare DO state.storage.setAlarm(Date.now() + 10_000) + async alarm() { ... }.

Building Python Actors

cd examples/python/apps/calculator
./build.sh

The build script:

  1. Generates Python bindings from WIT
  2. Compiles Python to WASM Component using componentize-py
  3. Produces a ~35MB WASM file (includes Python runtime)

Example Python Actor

from plexspaces import actor, handler, state
from generated.calculator_pb2 import AddRequest, AddResponse

@actor
class Calculator:
    invocation_count: int = state(default=0)

    @handler("add")
    def add(self, request: AddRequest) -> AddResponse:
        self.invocation_count += 1
        return AddResponse(result=sum(request.operands))

See examples/python/README.md for complete documentation.

TypeScript WASM Development

TypeScript actors use the same actor-world WIT world as Python. Use the TypeScript SDK: extend PlexSpacesActor<TState>, implement getDefaultState() and on<Op>(payload) handlers, then build with jco componentize (not Javy).

SDK Simplification: The SDK automatically generates WIT TypeScript types during build - client code doesn't need to run jco types or import generated files. The SDK abstracts all WIT details away, keeping client code simple.

Build (from examples/typescript/apps/bank_account):

  1. Install deps: npm install (includes @plexspaces/sdk, esbuild, jco)
  2. Build: ./scripts/build.sh — runs tsc, esbuild bundle (actor + SDK → single ESM), then jco componentize account_actor_bundle.mjs --wit wit/plexspaces-actor -o account_actor.wasm --disable all

Important: Use --disable all so the component only imports plexspaces:actor/host; the PlexSpaces runtime does not provide WASI 0.2.3 that jco would otherwise add.

SDK Simplification:

  • WIT TypeScript types are automatically generated by the SDK during build (npm run build in SDK)
  • Client code doesn't need to run jco types or import generated files
  • SDK uses iterative JSON serialization to avoid WASM recursion issues
  • Just extend PlexSpacesActor and implement handlers - SDK handles all WIT details

See examples/typescript/apps/bank_account/README.md and sdks/typescript/README.md for full docs.

WASM Actor State Persistence (Durability)

WASM actors support checkpoint-based durability via the get-state() and set-state() WIT interface functions. This follows the Cloudflare Durable Objects pattern.

How It Works

  1. Actor manages state internally: Your WASM actor maintains state in memory
  2. Framework calls get-state(): On shutdown or checkpoint interval, framework gets state
  3. State is persisted: Framework stores state snapshot in SQLite/PostgreSQL
  4. On restart, set-state() is called: Framework restores state from checkpoint

Implementing State Persistence

from generated.state_pb2 import ActorState

class StatefulActor:
    def __init__(self):
        self.data = ActorState()

    def get_state(self) -> bytes:
        """Called by framework to checkpoint state."""
        return self.data.SerializeToString()

    def set_state(self, state_bytes: bytes) -> None:
        """Called by framework to restore state on restart."""
        if state_bytes:
            self.data.ParseFromString(state_bytes)

Key Points

  • Proto-first state: State snapshots are raw bytes, typically protobuf messages defined alongside the actor
  • Typed errors: set-state() and get-state() use actor-world result<_, actor-error>
  • Graceful degradation: set-state() should handle empty input
  • Size matters: Keep state small for fast checkpointing

State Serialization: Float Safety

Float values in actor state are automatically sanitized for WASM safety. The runtime detects special IEEE 754 float values (NaN, Infinity, -Infinity) that are not valid in JSON and replaces them with safe defaults before serialization. On deserialization, these values are restored transparently.

This means actors can use float arithmetic freely (including operations that produce NaN or infinity) without worrying about state serialization failures. The sanitization and restoration process is fully transparent to the actor -- no special handling is required in actor code.

Inter-Actor Ask Pattern (Request-Reply)

WASM actors can communicate with each other using the ask pattern (request-reply), following the same semantics as Erlang's gen_server:call/2. The Python SDK exposes this via host.ask().

How it works:

  1. Caller actor invokes host.ask(target_id, msg_type, payload, timeout_ms)
  2. The SDK serializes the payload and calls the WIT host.ask function
  3. The Rust runtime creates a temporary sender actor with a canonical temporary-sender ActorId and a ReplyWaiter
  4. A request message is created with id = req-{ULID} and routed to the target actor
  5. Target actor's handle() method processes the message and returns a result
  6. The runtime wraps the result in a reply message with id = res-{ULID} and sends it back to the temporary sender
  7. The ReplyWaiter receives the reply and returns it to the caller

Message ID conventions:

  • Request messages: req-{ULID} (e.g., req-01JMXYZ...)
  • Reply messages: res-{ULID} (e.g., res-01JMXYZ...)
  • These prefixes enable tracing request/reply flows in logs

Example (Python):

from plexspaces import actor, handler, host, state

@actor
class Coordinator:
    @handler("run")
    def run(self) -> dict:
        # Ask a worker to compute something (request-reply)
        result = host.ask("worker-0//worker::my-app@node-1", "compute", {"x": 42}, timeout_ms=5000)
        return {"status": "ok", "worker_result": result}

@actor
class Worker:
    @handler("compute")
    def compute(self, x: int = 0) -> dict:
        return {"result": x * 2}

Debugging ask flow: Enable debug logging to trace the full message flow:

RUST_LOG=plexspaces_application=debug,plexspaces_actor::actor_registry=debug

This will show:

WASM ask: sending request via ActorRef  message_id=req-01JMX...  sender_id=coordinator//worker::app@node  recipient_id=worker-0//worker::app@node
registry ask: routing request to target  message_id=req-01JMX...  sender_id=ask_CORR//temp_sender::app@node  correlation_id=CORR
WasmActor handle_message: sending reply  request_id=req-01JMX...  reply_id=res-01JMX...  reply_to=ask_CORR//temp_sender::app@node
registry ask: reply received  request_id=req-01JMX...  reply_id=res-01JMX...
WASM ask: reply received  request_id=req-01JMX...  reply_id=res-01JMX...

Float Handling in Inter-Actor Messages

Important: The Python SDK's handle() return path uses json.dumps() directly, which serializes Python floats as JSON numbers. This ensures that inter-actor host.ask() responses preserve numeric types correctly.

Float sanitization (converting floats to strings) is only applied in the get_state() path for checkpoint persistence safety. The set_state() path reverses this with _desanitize_from_wasm(). This sanitization is not applied to handle() responses to avoid corrupting numeric values in inter-actor communication.

If you encounter "unsupported operand type(s) for +=: 'float' and 'str'" errors in inter-actor ask responses, ensure your WASM module is built with the latest SDK (>= v0.2.0) where this fix is applied.

Re-Instantiation After handle() (wasmtime Component Model)

Component-model WASM actors are re-instantiated after each handle() call. This is required because wasmtime 16.x raises a "cannot enter component instance" trap when a component is called a second time (see wasmtime#8943).

How it works:

  1. Actor receives a message → handle() is called on the WASM instance
  2. After handle() returns, the runtime calls get_state() to capture actor state
  3. A new WASM instance is created from the same compiled module
  4. set_state() restores the captured state on the new instance
  5. The new instance is ready for the next message

Concurrency Control:

  • Per-actor re-instantiation lock: Each WasmInstance has a semaphore (permit count 1) that serializes re-instantiations per actor. Both must be held where applicable: drop any component_state lock before acquiring the per-actor lock to avoid deadlock (see crates/wasm-runtime/tests/simple_actor_deadlock.rs).
  • Global instantiation cap: When pooling is enabled, WasmRuntime holds a global semaphore used for both initial instantiation (virtual actor activation) and re-instantiation (after handle()). The permit count is set by WasmConfig.max_concurrent_instantiations (default: 7) to stay under Wasmtime’s per-memory-stripe limit (e.g. 10). This avoids the "maximum concurrent limit of 10 for memory stripe 0 reached" error. If that error appears, reduce load or increase max_concurrent_instantiations in config.
  • Sequential processing per actor: Only one re-instantiation at a time per actor; messages queue in the mailbox.
  • Observability: Metrics track re-instantiation duration, errors, and queue depth (see plexspaces_wasm_reinstantiation_* metrics).

State preservation: The get_state()/set_state() cycle preserves all state() fields. Fields not declared with state() are lost across re-instantiation. For large derived data (e.g., data shards), regenerate from deterministic seeds rather than persisting.

When will this change? The wasmtime project is working on component-model-async support which will allow re-entrant component calls. Once PlexSpaces upgrades to a wasmtime version with this feature, re-instantiation will no longer be needed and per-message performance will improve significantly.

Impact on performance: Re-instantiation adds per-message overhead (typically 1-5ms for Python actors). The per-actor lock ensures reliable operation without hitting Wasmtime's concurrent limits, even under high message rates. For high-throughput single-actor scenarios, consider using traditional (non-component) WASM modules.

Metrics

State operations are fully instrumented with Prometheus metrics:

  • plexspaces_wasm_get_state_total: Total checkpoint calls
  • plexspaces_wasm_set_state_total: Total state restore calls
  • plexspaces_wasm_state_size_bytes: Size of persisted state

See Durability Documentation for complete details.

Supervisor Integration for WASM Actors

WASM actors are deployed under a supervisor tree that provides automatic restart on failure. This follows the Erlang/OTP supervision model.

Default Supervisor Configuration

When deploying a WASM application without explicit supervisor configuration, the server automatically creates a default supervisor:

Strategy: OneForOne (restart only the failed actor)
Max Restarts: 5
Children: The WASM actor as a permanent worker

How Supervisor Integration Works

The supervisor integration for WASM actors uses a unified approach:

  1. Factory Function Pattern: Each WASM actor has a factory function (StartFn) that can recreate the actor
  2. build_wasm_actor Helper: Single entry point for building WASM actors with full service wiring
  3. Supervisor.add_child(): Adds WASM actors to the supervisor with proper ChildSpec
  4. Automatic Restart: When an actor crashes, the supervisor calls the factory to recreate it

Architecture

WasmApplication
  └── Root Supervisor (one-for-one)
       ├── worker-1 (WASM actor) ← Factory can recreate on crash
       └── worker-2 (WASM actor) ← Factory can recreate on crash

Supervisor Tree Actor ID Format

Applications with supervisor trees create actors whose IDs incorporate the child name, behavior actor_type, namespace, and node ID. The format is:

name//actor_type::namespace@node_id

For example, given an application with namespace my-app deployed to node-1 with child identity name = worker-1, actor_type = my_worker, the actor ID will be:

worker-1//my_worker::my-app@node-1

This format ensures that all actors within a supervisor tree are uniquely identifiable and properly scoped to their namespace, even when multiple applications share the same node.

Key Components

Runtime plexspaces_actor::ChildSpec — created by the WASM layer with a factory, using the child’s resolved canonical identity:

// `child_actor_id` is the full canonical ActorId (from proto ActorIdentity + namespace + node).
// The supervisor process uses an opaque label via Supervisor::new(supervisor_label, ...) — not this ActorId.
let spec = ChildSpec::worker(child_actor_id, start_fn);

build_wasm_actor() - Unified helper that:

  • Wires up all services (TupleSpace, ObjectRegistry, JournalStorage, etc.)
  • Creates unstarted Actor with proper context
  • Returns (Actor, ActorRef) for supervisor management

Factory Function - Captured context for restart:

  • node, proto_child_spec, module_hash, runtime
  • Called by supervisor when actor needs restart

Supervision Strategies

Strategy Behavior Use Case
OneForOne Restart only failed actor Independent workers
OneForAll Restart all actors Tightly coupled actors
RestForOne Restart failed + started after Dependency chain

Example: Feature Flags with Supervisor

# Deploy feature flags service (supervisor auto-created)
./target/debug/plexspaces deploy \
  --node localhost:8090 \
  -i feature-flags-test \
  -n flags \
  -w examples/python/apps/feature_flags/feature_flags_actor.wasm

The deployed application automatically has:

  • OneForOne supervisor
  • Automatic restart on crash (up to 5 times)
  • Full service access (TupleSpace, ObjectRegistry, etc.)

Custom Supervisor Configuration

To customize supervisor settings, provide a config TOML file:

# app-config.toml
name = "my-app"
version = "1.0.0"
namespace = "my-app"  # Required: all actors scoped to this namespace

[supervisor]
strategy = "one_for_one"
max_restarts = 10
max_restart_window_seconds = 60

[[supervisor.children]]
name = "worker-1"
actor_type = "my_worker"
type = "worker"
restart = "permanent"
shutdown_timeout_seconds = 5

name and actor_type are the declaration-time slice (ActorIdentity); the server combines them with deploy namespace and node_id into the canonical ActorId string (name//actor_type::namespace@node_id). Use a lowercase actor_type slug matching plexspaces.common.v1.ActorIdentity (same pattern as the actor_type field on ActorId).

Optional behavior_kind on the same child is OTP-style metadata only (for example GenServer, GenEvent, Workflow). It must not be reused as actor_type: the behavior class / WASM dispatch key and BehaviorRegistry factory key is always the actor_type slug.

Deploy with custom config:

curl -X POST http://localhost:8000/api/v1/applications/deploy \
  -F "application_id=my-app" \
  -F "name=my-app" \
  -F "version=1.0.0" \
  -F "wasm_file=@my_actor.wasm" \
  -F "config=@app-config.toml"

Best Practices

  1. Use HTTP for Large Files: Always use HTTP multipart for files >5MB
  2. Optimize Before Deploy: Run wasm-opt on all WASM files
  3. Version Control: Tag WASM files with version numbers
  4. Content-Addressable: Use module hash for caching (automatic)
  5. Language Selection: Choose language based on size/performance requirements
  6. Test Locally First: Verify WASM file works before deploying to production
  7. Monitor Deployment: Check dashboard after deployment to verify application is running

Integration Tests

Integration tests are available in crates/node/tests/http_wasm_deployment.rs:

# Run tests (requires WASM files to be built first)
cargo test --package plexspaces-node --test http_wasm_deployment

# Build WASM files first
cd examples/simple/wasm_calculator
./scripts/build_python_actors.sh

Tests cover:

  • HTTP multipart deployment
  • HTTP undeployment
  • Size limit enforcement (100MB)
  • Error handling

References