Temporalex
Workflow orchestration for Elixir, built on the Temporal Core SDK (Rust) over Rustler NIFs.
Temporalex workflows read top-to-bottom as sequential code. Concurrency is explicit and structured — there is no implicit event loop. The runtime uses a deterministic cooperative scheduler that owns thread ordering, so command sequences are reproducible from the same activation transcript regardless of BEAM scheduling or mailbox timing.
Status: 0.3.0. This release is an architectural rewrite around a deterministic core, a
Temporalex.Backendboundary that isolates Temporal Core / Rust details, and structured concurrency primitivesphaseandparallel. The 0.x line is not backwards-compatible with 0.2.0. See CHANGELOG.md for the migration notes.Core design and scheduler authored by @hansihe; see
docs/scheduler_and_replay.mdanddocs/implementation_principles.md.
Install
# mix.exs
defp deps do
[{:temporalex, "~> 0.3.0"}]
end
Requirements: Elixir ~> 1.17, Rust toolchain (the NIF crate compiles on first
build against temporalio/sdk-rust v0.4.0).
Run a Temporal dev server
brew install temporal
temporal server start-dev
The Web UI lands at http://localhost:8233; the gRPC endpoint at
localhost:7233.
Define an activity
defmodule MyApp.Activities.Payment do
use Temporalex.Activity
defactivity charge(amount), start_to_close_timeout: 30_000 do
{:ok, "charge-#{amount}"}
end
# Local activity: runs in-process on the same worker, durable via a
# history marker. Use for short, deterministic work where the network
# round-trip to schedule a regular activity isn't worth it.
defactivity stamp(prefix), local: true, start_to_close_timeout: 5_000 do
{:ok, "#{prefix}-#{System.unique_integer([:positive])}"}
end
endStructured errors
Activities can raise Temporalex.ApplicationError (or return {:error, reason}); the workflow sees a typed exception with the cause preserved:
defactivity charge(amount) do
if amount > 10_000 do
raise %Temporalex.ApplicationError{
message: "amount exceeds limit",
type: "AmountTooLarge",
non_retryable: true
}
else
{:ok, amount}
end
end
# In the workflow:
case Activities.charge(amount) do
{:ok, charge} -> ...
{:error, %Temporalex.ActivityFailure{cause: %{type: "AmountTooLarge"}}} -> ...
endDefine a workflow
defmodule MyApp.Workflows.Checkout do
use Temporalex.Workflow
alias Temporalex.Workflow.API
def handle_query("status", _args, state), do: {:reply, state}
def run(args) do
API.publish_state(:charging)
{:ok, charge} = MyApp.Activities.Payment.charge(args["amount"])
API.publish_state(:awaiting_confirmation)
confirmed =
API.phase(false,
signal: %{
"confirm" => fn _args, _state -> {:stop, true} end,
"cancel" => fn _args, _state -> {:stop, false} end
},
timeout: :timer.minutes(5)
)
case confirmed do
{:timeout, _state} -> {:error, :timed_out}
true -> {:ok, %{charge: charge, confirmed: true}}
false -> {:error, :user_cancelled}
end
end
endStart a worker
children = [
{Temporalex.Worker,
name: MyApp.Temporal,
backend: Temporalex.Backend.TemporalCore,
target: "http://127.0.0.1:7233",
namespace: "default",
task_queue: "checkout",
workflows: [MyApp.Workflows.Checkout],
activities: [MyApp.Activities.Payment],
# :etf (default) preserves full Elixir term fidelity.
# :json makes payloads renderable by `temporal` CLI and non-Elixir
# clients, at the cost of lossy term encoding (atoms → strings,
# tuples → unsupported).
payload_codec: :etf}
]
Supervisor.start_link(children, strategy: :one_for_one)Drive workflows from a client
{:ok, handle} =
Temporalex.Client.start_workflow(
MyApp.Temporal,
MyApp.Workflows.Checkout,
%{"amount" => 100},
workflow_id: "checkout-#{order_id}"
)
:ok = Temporalex.Client.signal_workflow(handle, "confirm")
{:ok, status} = Temporalex.Client.query_workflow(handle, "status")
{:ok, result} = Temporalex.Client.get_result(handle)
The full client surface: start_workflow, get_result, signal_workflow,
query_workflow, update_workflow, cancel_workflow, terminate_workflow,
describe_workflow.
Programming model
Workflows are a single run/1 function. Concurrency enters only through
phase and parallel, which act as structured concurrency scopes — every
async handler spawned within a scope must complete before the scope returns.
| Primitive | Purpose |
|---|---|
Activities.Module.fun(args) | Execute an activity. Blocks until resolved. |
API.sleep(ms) | Durable timer. |
API.wait_for_signal(name) | Pop one signal from the buffer. |
API.publish_state(state) | Update the snapshot that queries see. |
API.now/0API.random/0API.uuid4/0 | Deterministic time/random. |
API.patched?(id) | Workflow versioning, replay-safe. |
API.phase(state, opts) |
Message-processing scope with signal/update handlers and an optional :timeout. |
API.parallel(fns) | Cooperatively scheduled fan-out. Results in input order. |
API.update_state(fn) |
Atomically transform the enclosing phase's state from inside an {:async, fn, _} handler. |
API.execute_child_workflow(mod, input, opts) | Start a child workflow, block until it completes. |
API.signal_child_workflow(id, name, args) | Send a durable signal to a child workflow. |
Full details, return-value contracts, and the determinism rationale:
docs/programming_model.md— public workflow programming modeldocs/scheduler_and_replay.md— scheduler rounds, pause points, replay matchingdocs/implementation_principles.md— internal invariants and admission rulesdocs/sdk_overview.md— architecture map
Testing
Temporalex.Backend.Test is an in-memory backend that lets you drive a worker
with core activation structs directly — no Temporal server required. The same
Temporalex.Server and Temporalex.Core.Executor that handle real traffic
also handle the test backend, so workflow code under test runs the production
codepath.
start_supervised!(
{Temporalex.Worker,
name: MyApp.Temporal,
backend: Temporalex.Backend.Test,
workflows: [MyApp.Workflows.Checkout],
activities: [MyApp.Activities.Payment]}
)
See test/temporalex/server_integration_test.exs for full activation and
activity-task transcripts.
Project layout
lib/temporalex/
workflow.ex use Temporalex.Workflow
workflow/api.ex sequential primitives, phase, parallel
activity.ex defactivity macro
activity/context.ex heartbeat, cancelled? for activity bodies
client.ex start / get_result / signal / query / update / cancel / terminate / describe
worker.ex Supervisor — what users add to their tree
server.ex Worker server: backend state, executor registry, activation routing
core/executor.ex deterministic workflow executor (scheduler + replay)
core/structs.ex internal protocol: Activation, Job, Command, Completion, Op
core/test_harness.ex in-process harness for testing the core directly
backend.ex Backend behaviour
backend/test.ex in-memory backend for tests
backend/temporal_core.ex Rustler-backed Temporal Core backend
native.ex Rustler NIF surface (do not call directly)
native/temporalex_nif/
src/ Rust NIF crateContributing
The architecture is documented in docs/. Start with
docs/sdk_overview.md. The docs/implementation_principles.md
admission rule applies to any new workflow API: a primitive only enters the
public surface if it has a precise replay contract and can be tested without
the real Temporal backend.
License
MIT — see LICENSE.