Skip to main content

README.md

# Temporalex

Workflow orchestration for Elixir, powered by the [Temporal](https://temporal.io/)
Core SDK (Rust) over Rustler NIFs.

Temporalex workflows read top-to-bottom as sequential code. Concurrency is
explicit, scoped, and structured — there is no implicit event loop. Activities,
timers, signals, queries, updates, child workflows, and continue-as-new all
work the same way they do in the official SDKs, but the programming surface is
designed for Elixir, not transliterated from another language.

> **Status: pre-release.** v0.2 is a clean rewrite from v0.1; the API is not
> backwards-compatible. 252 tests pass against a live Temporal dev server.
> Suitable for evaluation. Do not run on critical production paths yet.

---

## Install

```elixir
# mix.exs
defp deps do
  [{:temporalex, github: "cgreeno/temporalex", branch: "main"}]
end
```

Requirements: Elixir ~> 1.15, Rust ~> 1.94 (the NIF crate compiles on first
build).

## Run a Temporal dev server

```bash
brew install temporal
temporal server start-dev
```

The Web UI lands at <http://localhost:8233>; the gRPC endpoint at
`localhost:7233`.

---

## Define an activity

```elixir
defmodule MyApp.Activities.Payment do
  use Temporalex.Activity

  defactivity charge(amount) do
    {:ok, "charge-#{amount}"}
  end

  # Local activities run in-process, are recorded in workflow history,
  # and survive worker crashes — the durable replacement for `side_effect/1`.
  defactivity tag_id(prefix), local: true do
    {:ok, "#{prefix}-#{System.unique_integer([:positive])}"}
  end
end
```

## Define a workflow

```elixir
defmodule MyApp.Workflows.Checkout do
  use Temporalex.Workflow

  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.receive(false,
        signal: %{
          "confirm" => fn _payload, _ -> {:stop, true} end,
          "cancel"  => fn _payload, _ -> {:stop, false} end
        },
        timeout: :timer.minutes(5)
      )

    if confirmed do
      {:ok, %{charge: charge, confirmed: true}}
    else
      {:error, :user_cancelled}
    end
  end
end
```

## Start a worker

```elixir
children = [
  {Temporalex.Worker,
   url: "http://localhost:7233",
   namespace: "default",
   task_queue: "checkout",
   workflows: [MyApp.Workflows.Checkout],
   activities: [MyApp.Activities.Payment]}
]

Supervisor.start_link(children, strategy: :one_for_one)
```

## Drive workflows from a client

```elixir
{:ok, client} = Temporalex.Client.connect("http://localhost:7233")

{:ok, _run_id} =
  Temporalex.Client.start_workflow(client, "default",
    workflow_id: "checkout-#{order_id}",
    workflow_type: "MyApp.Workflows.Checkout",
    task_queue: "checkout",
    input: %{"amount" => 100},
    execution_timeout_ms: :timer.hours(1)
  )

:ok = Temporalex.Client.signal_workflow(client, "default",
        workflow_id: "checkout-#{order_id}", signal_name: "confirm")

{:ok, status} = Temporalex.Client.query_workflow(client, "default",
                  workflow_id: "checkout-#{order_id}", query_type: "status")
```

---

## Programming model

Workflows are a single function. Concurrency enters only through `receive` and
`parallel`, which scope all spawned work — every async handler must complete
before the scope returns.

| Primitive | Where | Purpose |
| --- | --- | --- |
| `defactivity` calls | anywhere | Schedule an activity, block until it resolves. |
| `API.execute_local_activity/3` | anywhere | Same, but in-process and durable. |
| `API.sleep(ms)` | anywhere | Durable timer. |
| `API.wait_for_signal(name)` | anywhere | Pop one signal from the buffer. |
| `API.publish_state(state)` | anywhere | Update the snapshot queries see. |
| `API.patched?(id)` | anywhere | Workflow versioning, replay-safe. |
| `API.receive(state, opts)` | anywhere | Message-processing scope with signal/update handlers. |
| `API.parallel(fns)` | anywhere | Concurrent fan-out, results in input order. |
| `API.update_state(fn)` | inside an async handler | Atomically transform the receive's reducer state. |

Full details, return-value contracts, and design rationale: see
[`docs/architecture.md`](docs/architecture.md).

---

## Testing

`Temporalex.Testing` runs workflows step-by-step without a Temporal server.
Each blocking primitive surfaces as a descriptor you resolve in the test:

```elixir
test "checkout charges then waits for confirmation" do
  {:ok, exec} = Temporalex.Testing.start_workflow(MyApp.Workflows.Checkout, %{"amount" => 50})

  assert {:activity, call} = Temporalex.Testing.next(exec)
  assert call.type == "MyApp.Activities.Payment.charge"

  assert {:receive, info} = Temporalex.Testing.resolve(exec, {:ok, "charge-50"})
  assert "confirm" in info.signals

  Temporalex.Testing.send_signal(exec, "confirm")
  Process.sleep(20)

  assert {:ok, %{confirmed: true}} = Temporalex.Testing.next(exec)
end
```

Replay-state hooks are available too — see
`Temporalex.Testing.start_workflow/3` `:is_replaying` and `:seen_patches`
options.

---

## Project layout

```
lib/temporalex/
  workflow.ex          use Temporalex.Workflow + the API module
  workflow/api.ex      sequential primitives, receive, parallel
  activity.ex          defactivity macro
  activity/context.ex  heartbeat, cancelled? for activity bodies
  worker.ex            Supervisor — what users add to their tree
  worker/server.ex     poll-loop owner + dispatcher
  worker/executor.ex   per-workflow-task GenServer (production)
  worker/replay.ex     pure replay-log construction & consumption
  testing.ex           step-by-step test driver
  testing/executor.ex  per-workflow-task GenServer (testing)
  client.ex            start/signal/query/cancel from outside workflows
  converter.ex         ETF / JSON / binary payload conversion
  native.ex            Rustler NIF surface (do not call directly)
  runtime.ex           per-app Tokio runtime singleton
native/temporalex_native/
  src/                 Rust NIF crate — proto bridge, client ops, worker
```

---

## Contributing

The project is in active development. [`docs/architecture.md`](docs/architecture.md)
is the source of truth for the workflow programming model — read it before
proposing changes to the public API.

## License

MIT — see [LICENSE](LICENSE).