Repository files navigation

Continuum

English | 简体中文

CIHex.pmDocumentationLicense

Continuum lets an Elixir function survive your application crashing halfway through it.

defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total)# ─── kill -9 the entire VM right here ───{:ok,shipment}=activityFulfillment.ship(id){:ok,%{charge: charge,shipment: shipment}}end

Kill the node on that middle line and nothing is lost and nothing is repeated. The process is gone; the run is not. A new VM picks the run up, executes run/1 again from the top, replays the charge out of the journal instead of calling the payment gateway a second time, and carries on into ship.

Continuum is a durable execution engine — Temporal's programming model, but OTP-native and Postgres-backed. No separate cluster service, no paid SaaS dependency, no polyglot SDK. It lives in your application's supervision tree and uses the database you already run.

See it happen

mix continuum.demo is not a diagram. It runs that workflow against a real Postgres, and then really kills the BEAM.

Continuum surviving a hard crash mid-workflow

What you just watched, in order:

  1. Start a checkout run.
  2. ChargeCard executes and commits its result to the journal.
  3. :erlang.halt/1 — no graceful shutdown, no terminate/2, no cleanup.
  4. Start a brand new VM.
  5. Boot recovery and the dispatcher find a run leased by a node that no longer exists.
  6. The workflow body runs again from its first line — and the charge does not happen again.
  7. ShipOrder runs for the first time, and the run completes.
docker compose up -d # Postgres on localhost:5433
mix continuum.demo # phase 1: charge the card, then kill the VM
mix continuum.demo --resume # phase 2: replay, ship, never re-charge
The same demo as plain text, if your client blocks GIFs
$ mix continuum.demo
── phase 1 — a checkout that dies halfway through ────────────────────
[demo] starting checkout for order #123 (4200 cents)
[continuum] run ac7e3425 started
[workflow] checkout started
[continuum] activity scheduled: ContinuumDemo.ChargeCard.run
[continuum] run ac7e3425 suspended — waiting on durable work
[continuum] activity completed: ContinuumDemo.ChargeCard.run
[workflow] card charged pay_5b1a7dbe
[demo] *** KILLING THE BEAM (erlang:halt/1, no shutdown, no cleanup) ***
[demo] the shipment has not been scheduled — the charge is already journaled
$ mix continuum.demo --resume
── phase 2 — a brand new VM picks the run back up ────────────────────
[continuum] found run ac7e3425 in state=suspended, leased by a node that no longer exists
── continuum_events for run ac7e3425 ─────────────────────────────────
0 18:09:44.495 workflow_log %{message: "checkout started", ...}
1 18:09:44.498 activity_scheduled %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
2 18:09:45.488 activity_completed %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
3 18:09:45.505 workflow_log %{message: "card charged pay_5b1a7dbe", ...}
[demo] the charge is already journaled, so replay will not re-run it
[continuum] dispatcher claimed 1 orphaned run(s) — lease had expired
[continuum] run ac7e3425 resumed — replaying its journal from event 0
[continuum] activity scheduled: ContinuumDemo.ShipOrder.run
[continuum] activity completed: ContinuumDemo.ShipOrder.run
[workflow] order shipped ship_c66c1cbb
[continuum] run ac7e3425 completed
[demo] this VM took 1.2s to finish someone else's work
── verdict ───────────────────────────────────────────────────────────
✓ card charged exactly once — 1 CHARGE line(s) in the ledger
✓ order shipped exactly once — 1 SHIP line(s) in the ledger
✓ replay stayed silent — this VM printed 1 workflow log line, not the 3 in the journal
✓ the whole function body ran again, harmlessly — run/1 executed twice;
its side effects executed once each
The function survived the VM dying halfway through it.

The demo's activities append to tmp/continuum_demo/ledger.log, which stands in for the outside world. That file, not the log lines, is the actual claim:

2026-08-26T18:09:45.486Z CHARGE order=123 payment=pay_5b1a7dbe cents=4200
2026-08-26T18:10:07.132Z SHIP order=123 shipment=ship_c66c1cbb

One CHARGE. ChargeCard deliberately declares no idempotency key, so nothing deduplicated a second call to the gateway — there was no second call.

Run mix continuum.demo --observer in a second terminal to watch the same journal fill in through the Observer LiveView while it happens, and mix continuum.demo --help for the rest. The workflow, the activities, and the kill switch are about 150 lines in dev/demo/.

Why this works

run/1 is a pure function of its journal. Every effect — activity, await signal, timer, Continuum.now/0, even Continuum.log/2 — goes through one bridge that either replays the recorded result sitting at the current cursor, or suspends the run to go produce a new one. Re-executing the body from the top is therefore free of side effects right up to the point where history ends.

That only holds if the code between effects is deterministic, so Continuum does not ask you to be careful about it. A compile-time AST scanner rejects DateTime.utc_now/0, :rand.uniform/0, :ets.*, send/2, File.*, Logger.* and friends inside workflow code, each with a remediation hint naming the replacement. Every cursor position also carries a structured identity ({kind, module, function, line, hash, ordinal}), so editing a workflow under a run that is mid-flight raises a loud Continuum.ReplayDriftError instead of silently taking a different branch than the one that was journaled.

Postgres is the only moving part — journal, lease store, timer wheel, and signal bus. Every write is a compare-and-set on a fencing token, so a node you thought was dead cannot come back and write into the history of a run somebody else has already taken over.

What you get

Continuum is to durable execution what Phoenix is to web and Oban is to job queues: the obvious answer to "how do I run a multi-step business process that survives a crash?" for Elixir-first teams.

  • Straight-line code. Express orchestration as ordinary Elixir control flow — case, with, comprehensions. Effects go through activity/2, await signal, and timer; everything else is pure.
  • Deterministic replay. A run re-executes from the top on every wake. Structured cursor identity means any divergence between replay and the original execution surfaces as a loud Continuum.ReplayDriftError, never silent corruption.
  • One dependency. Postgres is the only thing you need to operate — it is the journal, the lease store, the timer wheel, and the signal bus (LISTEN/NOTIFY).
  • It's just OTP. Continuum is a supervision tree you add to your own app. Crash recovery, leasing, and back-pressure are built on processes, not an external coordinator.

Deliberately out of scope: polyglot SDKs, cross-language activities, a separate cluster service, and Kubernetes operators.

Quickstart

defmoduleMyApp.OrderFlowdouseContinuum.Workflow,version: 1defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total),retry: [max_attempts: 5,backoff: :exponential],compensate: {Payments,:refund,[id]}caseawaitsignal(:fraud_review,timeout: hours(24))do:approved->activityFulfillment.ship(id):rejected->compensate(charge){:error,:fraud_rejected}:timeout->activityFulfillment.ship(id)endendend
{:ok,run_id}=Continuum.start(MyApp.OrderFlow,%{order_id: "o1",items: [...]})# from anywhere — durable mailbox, survives restarts:ok=Continuum.signal(run_id,:fraud_review,:approved)# blocks via PubSub with poll fallback{:ok,%{state: :completed,result: result}}=Continuum.await(run_id,30_000)

Installation

Continuum supports Elixir 1.19 and 1.20. CI exercises the compatibility endpoints on Elixir 1.19 / OTP 27 / PostgreSQL 14 and Elixir 1.20 / OTP 29 / PostgreSQL 18, in addition to scheduled failure-injection lanes.

Add Continuum and a Postgres driver to your dependencies:

defdepsdo[{:continuum,"~> 0.8.1"},{:postgrex,"~> 0.19"}]end

Point Continuum at your repo:

# config/config.exsconfig:continuum,repo: MyApp.Repo,journal: Continuum.Runtime.Journal.Postgres

Generate and run the migration:

mix continuum.gen.migration --repo MyApp.Repo
mix ecto.migrate

Add Continuum's runtime children to your supervision tree, after your repo:

defstart(_type,_args)dochildren=[MyApp.Repo,{Phoenix.PubSub,name: MyApp.PubSub}]++Continuum.children()++[MyAppWeb.Endpoint]Supervisor.start_link(children,strategy: :one_for_one,name: MyApp.Supervisor)end

Features

Determinism by construction

  • Workflow code is pure-by-construction and re-executed top-to-bottom on every wake; only effects produce side-visible work.
  • A compile-time AST scanner rejects non-deterministic calls (DateTime.utc_now, :rand.*, :ets.*, Process.send, Kernel.apply, …) with remediation hints. Helper modules opt in via use Continuum.Pure or a config :continuum, trusted_modules: [...] allowlist.
  • Deterministic primitives — Continuum.now/0, today/0, uuid4/0, random/0, and the side_effect/1 escape hatch — capture stable cursor identity at compile time.

Durable execution

  • Postgres journal with lease + fencing-token CAS on every write. A stolen lease produces a write failure and terminates the stale engine — it never corrupts history.
  • Activity execution through the built-in worker pool by default, or an optional Continuum.Oban executor for teams that already operate Oban. Continuum keeps retry/timeout policy, idempotency, and fencing-token commits in its own durable task table either way.
  • Durable timers and signals over pg_notify/LISTEN. await signal(name, timeout: ms) resolves the signal/timeout race deterministically.
  • Crash survival. Kill the engine pid mid-flight; the dispatcher re-leases the run and replay completes from the journaled history. Boot-time recovery rescues orphaned runs, tasks, and timers without stealing live remote leases.
  • Cross-run idempotency keyed on (activity_module, idempotency_key), so activities are exactly-once-ish across runs.

Workflow composition

  • Sagas / compensation — attach compensate: to an activity, then compensate/1 or compensate_all/0 to roll back completed work in deterministic LIFO (or parallel) order.
  • Parent/child workflowsawait child Mod.run(input), start_child/3, and await_child/1 for durable fan-out/fan-in.
  • continue_as_new/1 — complete the current run and start a successor with fresh history for long-running loops.
  • Workflow versioning — journaled Continuum.patched?/1 markers for safe in-place edits, and content-addressed (workflow, version_hash) dispatch that leaves a run suspended when code is missing rather than replaying through changed logic. A node with the matching version can safely resume it later.

Operations & observability

  • Continuum.Observer — an optional Phoenix LiveView with a runs index, a decoded per-run event timeline, and operator actions for cancelling a run and injecting a signal.
  • Continuum.OpenTelemetry — an opt-in bridge that turns Continuum telemetry into run_attempt/activity_attempt spans, linked back through a persisted W3C traceparent.
  • 24+ documented telemetry events under the [:continuum, …] prefix.
  • Operator tooling — monthly-partitioned events, opt-in history snapshots, the read-only mix continuum.audit, and dry-run-by-default cleanup tasks.

Multi-tenancy & clustering

  • Named multi-instance runtimes via Continuum.children(name:, repo:), each bound to its own Ecto repo.
  • Namespaces — a soft tenant boundary for list/query; single-run operations stay keyed by global run_id.
  • Search attributes and structured queriesattributes: / Continuum.set_attributes/3 plus Continuum.list_runs/1,2.
  • Cluster-aware wake routing over :pg for cross-node wakeups. The Postgres lease and fencing token remain the sole authority for writes.

Testing

Continuum.Test provides an in-memory journal for fast unit tests, Postgres helpers for integration tests, signal/timer injection, golden-history replay, and an opt-in paranoid re-replay mode that catches divergence.

Parent/child example

defmoduleMyApp.BatchFlowdouseContinuum.Workflow,version: 1defrun(%{order_ids: ids})doids|>Enum.map(fnid->start_childMyApp.OrderFlow,%{order_id: id},id: idend)|>Enum.map(&await_child/1)endend

Observer

The optional Continuum.Observer LiveView lists runs, renders the journal event timeline per run, and includes an operational health panel for partitions, workflow versions, durable wakes, timers, leases, activities, and signals. It exposes operator actions for cancelling a run, sending a signal, and previewing fenced repairs. It is mounted from a host Phoenix router and ships no authentication of its own — wrap it in your existing admin pipeline.

Continuum Observer runs index

importContinuum.Observer.Routerscope"/admin"dopipe_through[:browser,:authenticate_admin]continuum_observer"/continuum",instance: :myapp_continuumend

To see the UI locally, run the crash demo's read-only Observer node:

docker compose up -d
mix continuum.demo --observer # http://localhost:4000/continuum

Leave it running in one terminal and drive mix continuum.demo / mix continuum.demo --resume in another: the run appears, stalls with a dead node's lease on it, and then completes under a different owner. That pane starts no dispatcher, so it watches rather than resuming the run itself. See guides/observer.md for production mount instructions.

Documentation

Full docs are published on HexDocs. The guides cover the entire surface:

  • Your first workflow
  • Activities, retries, and idempotency · Oban activity executor
  • Determinism rules and replay drift
  • Sagas and compensation · Child workflows · Long-running workflows
  • Patching workflows · Workflow versioning
  • Multi-instance Continuum · Clustering · Namespaces
  • Search attributes and structured queries
  • Operations · Auditing · Observer · Observability (OpenTelemetry) · Snapshots

See examples/continuum_example_orders for a Phoenix app exercising activity → signal/timeout → compensation, parent/child batches, continue_as_new, per-workflow snapshots, namespaces, the Observer, and OpenTelemetry.

Upgrading? See the migration guides.

Status

Continuum is v0.8.1 (pre-1.0). The durable engine, determinism enforcement, workflow composition, observability, and clustering surface are implemented and covered by tests, including crash-resume, lease-fencing races, and property-based replay. APIs may still change before 1.0 — pin to a specific 0.x in production. See CHANGELOG.md for release history.

Development

A docker-compose.yml brings up Postgres for local development and tests.

mix deps.get
docker compose up -d # Postgres on localhost:5433
mix compile --warnings-as-errors
mix test# unit + integration suite
mix test.cluster # real :peer cluster tests (run separately)
mix format
mix continuum.demo --help # the crash-recovery demo

License

Copyright 2026 The Continuum Authors. (yyeger)

Licensed under the Apache License, Version 2.0.

About

OTP-native durable execution engine for Elixir.

Resources

Stars

67 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

Continuum

English | 简体中文

CIHex.pmDocumentationLicense

Continuum lets an Elixir function survive your application crashing halfway through it.

defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total)# ─── kill -9 the entire VM right here ───{:ok,shipment}=activityFulfillment.ship(id){:ok,%{charge: charge,shipment: shipment}}end

Kill the node on that middle line and nothing is lost and nothing is repeated. The process is gone; the run is not. A new VM picks the run up, executes run/1 again from the top, replays the charge out of the journal instead of calling the payment gateway a second time, and carries on into ship.

Continuum is a durable execution engine — Temporal's programming model, but OTP-native and Postgres-backed. No separate cluster service, no paid SaaS dependency, no polyglot SDK. It lives in your application's supervision tree and uses the database you already run.

See it happen

mix continuum.demo is not a diagram. It runs that workflow against a real Postgres, and then really kills the BEAM.

Continuum surviving a hard crash mid-workflow

What you just watched, in order:

  1. Start a checkout run.
  2. ChargeCard executes and commits its result to the journal.
  3. :erlang.halt/1 — no graceful shutdown, no terminate/2, no cleanup.
  4. Start a brand new VM.
  5. Boot recovery and the dispatcher find a run leased by a node that no longer exists.
  6. The workflow body runs again from its first line — and the charge does not happen again.
  7. ShipOrder runs for the first time, and the run completes.
docker compose up -d # Postgres on localhost:5433
mix continuum.demo # phase 1: charge the card, then kill the VM
mix continuum.demo --resume # phase 2: replay, ship, never re-charge
The same demo as plain text, if your client blocks GIFs
$ mix continuum.demo
── phase 1 — a checkout that dies halfway through ────────────────────
[demo] starting checkout for order #123 (4200 cents)
[continuum] run ac7e3425 started
[workflow] checkout started
[continuum] activity scheduled: ContinuumDemo.ChargeCard.run
[continuum] run ac7e3425 suspended — waiting on durable work
[continuum] activity completed: ContinuumDemo.ChargeCard.run
[workflow] card charged pay_5b1a7dbe
[demo] *** KILLING THE BEAM (erlang:halt/1, no shutdown, no cleanup) ***
[demo] the shipment has not been scheduled — the charge is already journaled
$ mix continuum.demo --resume
── phase 2 — a brand new VM picks the run back up ────────────────────
[continuum] found run ac7e3425 in state=suspended, leased by a node that no longer exists
── continuum_events for run ac7e3425 ─────────────────────────────────
0 18:09:44.495 workflow_log %{message: "checkout started", ...}
1 18:09:44.498 activity_scheduled %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
2 18:09:45.488 activity_completed %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
3 18:09:45.505 workflow_log %{message: "card charged pay_5b1a7dbe", ...}
[demo] the charge is already journaled, so replay will not re-run it
[continuum] dispatcher claimed 1 orphaned run(s) — lease had expired
[continuum] run ac7e3425 resumed — replaying its journal from event 0
[continuum] activity scheduled: ContinuumDemo.ShipOrder.run
[continuum] activity completed: ContinuumDemo.ShipOrder.run
[workflow] order shipped ship_c66c1cbb
[continuum] run ac7e3425 completed
[demo] this VM took 1.2s to finish someone else's work
── verdict ───────────────────────────────────────────────────────────
✓ card charged exactly once — 1 CHARGE line(s) in the ledger
✓ order shipped exactly once — 1 SHIP line(s) in the ledger
✓ replay stayed silent — this VM printed 1 workflow log line, not the 3 in the journal
✓ the whole function body ran again, harmlessly — run/1 executed twice;
its side effects executed once each
The function survived the VM dying halfway through it.

The demo's activities append to tmp/continuum_demo/ledger.log, which stands in for the outside world. That file, not the log lines, is the actual claim:

2026-08-26T18:09:45.486Z CHARGE order=123 payment=pay_5b1a7dbe cents=4200
2026-08-26T18:10:07.132Z SHIP order=123 shipment=ship_c66c1cbb

One CHARGE. ChargeCard deliberately declares no idempotency key, so nothing deduplicated a second call to the gateway — there was no second call.

Run mix continuum.demo --observer in a second terminal to watch the same journal fill in through the Observer LiveView while it happens, and mix continuum.demo --help for the rest. The workflow, the activities, and the kill switch are about 150 lines in dev/demo/.

Why this works

run/1 is a pure function of its journal. Every effect — activity, await signal, timer, Continuum.now/0, even Continuum.log/2 — goes through one bridge that either replays the recorded result sitting at the current cursor, or suspends the run to go produce a new one. Re-executing the body from the top is therefore free of side effects right up to the point where history ends.

That only holds if the code between effects is deterministic, so Continuum does not ask you to be careful about it. A compile-time AST scanner rejects DateTime.utc_now/0, :rand.uniform/0, :ets.*, send/2, File.*, Logger.* and friends inside workflow code, each with a remediation hint naming the replacement. Every cursor position also carries a structured identity ({kind, module, function, line, hash, ordinal}), so editing a workflow under a run that is mid-flight raises a loud Continuum.ReplayDriftError instead of silently taking a different branch than the one that was journaled.

Postgres is the only moving part — journal, lease store, timer wheel, and signal bus. Every write is a compare-and-set on a fencing token, so a node you thought was dead cannot come back and write into the history of a run somebody else has already taken over.

What you get

Continuum is to durable execution what Phoenix is to web and Oban is to job queues: the obvious answer to "how do I run a multi-step business process that survives a crash?" for Elixir-first teams.

  • Straight-line code. Express orchestration as ordinary Elixir control flow — case, with, comprehensions. Effects go through activity/2, await signal, and timer; everything else is pure.
  • Deterministic replay. A run re-executes from the top on every wake. Structured cursor identity means any divergence between replay and the original execution surfaces as a loud Continuum.ReplayDriftError, never silent corruption.
  • One dependency. Postgres is the only thing you need to operate — it is the journal, the lease store, the timer wheel, and the signal bus (LISTEN/NOTIFY).
  • It's just OTP. Continuum is a supervision tree you add to your own app. Crash recovery, leasing, and back-pressure are built on processes, not an external coordinator.

Deliberately out of scope: polyglot SDKs, cross-language activities, a separate cluster service, and Kubernetes operators.

Quickstart

defmoduleMyApp.OrderFlowdouseContinuum.Workflow,version: 1defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total),retry: [max_attempts: 5,backoff: :exponential],compensate: {Payments,:refund,[id]}caseawaitsignal(:fraud_review,timeout: hours(24))do:approved->activityFulfillment.ship(id):rejected->compensate(charge){:error,:fraud_rejected}:timeout->activityFulfillment.ship(id)endendend
{:ok,run_id}=Continuum.start(MyApp.OrderFlow,%{order_id: "o1",items: [...]})# from anywhere — durable mailbox, survives restarts:ok=Continuum.signal(run_id,:fraud_review,:approved)# blocks via PubSub with poll fallback{:ok,%{state: :completed,result: result}}=Continuum.await(run_id,30_000)

Installation

Continuum supports Elixir 1.19 and 1.20. CI exercises the compatibility endpoints on Elixir 1.19 / OTP 27 / PostgreSQL 14 and Elixir 1.20 / OTP 29 / PostgreSQL 18, in addition to scheduled failure-injection lanes.

Add Continuum and a Postgres driver to your dependencies:

defdepsdo[{:continuum,"~> 0.8.1"},{:postgrex,"~> 0.19"}]end

Point Continuum at your repo:

# config/config.exsconfig:continuum,repo: MyApp.Repo,journal: Continuum.Runtime.Journal.Postgres

Generate and run the migration:

mix continuum.gen.migration --repo MyApp.Repo
mix ecto.migrate

Add Continuum's runtime children to your supervision tree, after your repo:

defstart(_type,_args)dochildren=[MyApp.Repo,{Phoenix.PubSub,name: MyApp.PubSub}]++Continuum.children()++[MyAppWeb.Endpoint]Supervisor.start_link(children,strategy: :one_for_one,name: MyApp.Supervisor)end

Features

Determinism by construction

  • Workflow code is pure-by-construction and re-executed top-to-bottom on every wake; only effects produce side-visible work.
  • A compile-time AST scanner rejects non-deterministic calls (DateTime.utc_now, :rand.*, :ets.*, Process.send, Kernel.apply, …) with remediation hints. Helper modules opt in via use Continuum.Pure or a config :continuum, trusted_modules: [...] allowlist.
  • Deterministic primitives — Continuum.now/0, today/0, uuid4/0, random/0, and the side_effect/1 escape hatch — capture stable cursor identity at compile time.

Durable execution

  • Postgres journal with lease + fencing-token CAS on every write. A stolen lease produces a write failure and terminates the stale engine — it never corrupts history.
  • Activity execution through the built-in worker pool by default, or an optional Continuum.Oban executor for teams that already operate Oban. Continuum keeps retry/timeout policy, idempotency, and fencing-token commits in its own durable task table either way.
  • Durable timers and signals over pg_notify/LISTEN. await signal(name, timeout: ms) resolves the signal/timeout race deterministically.
  • Crash survival. Kill the engine pid mid-flight; the dispatcher re-leases the run and replay completes from the journaled history. Boot-time recovery rescues orphaned runs, tasks, and timers without stealing live remote leases.
  • Cross-run idempotency keyed on (activity_module, idempotency_key), so activities are exactly-once-ish across runs.

Workflow composition

  • Sagas / compensation — attach compensate: to an activity, then compensate/1 or compensate_all/0 to roll back completed work in deterministic LIFO (or parallel) order.
  • Parent/child workflowsawait child Mod.run(input), start_child/3, and await_child/1 for durable fan-out/fan-in.
  • continue_as_new/1 — complete the current run and start a successor with fresh history for long-running loops.
  • Workflow versioning — journaled Continuum.patched?/1 markers for safe in-place edits, and content-addressed (workflow, version_hash) dispatch that leaves a run suspended when code is missing rather than replaying through changed logic. A node with the matching version can safely resume it later.

Operations & observability

  • Continuum.Observer — an optional Phoenix LiveView with a runs index, a decoded per-run event timeline, and operator actions for cancelling a run and injecting a signal.
  • Continuum.OpenTelemetry — an opt-in bridge that turns Continuum telemetry into run_attempt/activity_attempt spans, linked back through a persisted W3C traceparent.
  • 24+ documented telemetry events under the [:continuum, …] prefix.
  • Operator tooling — monthly-partitioned events, opt-in history snapshots, the read-only mix continuum.audit, and dry-run-by-default cleanup tasks.

Multi-tenancy & clustering

  • Named multi-instance runtimes via Continuum.children(name:, repo:), each bound to its own Ecto repo.
  • Namespaces — a soft tenant boundary for list/query; single-run operations stay keyed by global run_id.
  • Search attributes and structured queriesattributes: / Continuum.set_attributes/3 plus Continuum.list_runs/1,2.
  • Cluster-aware wake routing over :pg for cross-node wakeups. The Postgres lease and fencing token remain the sole authority for writes.

Testing

Continuum.Test provides an in-memory journal for fast unit tests, Postgres helpers for integration tests, signal/timer injection, golden-history replay, and an opt-in paranoid re-replay mode that catches divergence.

Parent/child example

defmoduleMyApp.BatchFlowdouseContinuum.Workflow,version: 1defrun(%{order_ids: ids})doids|>Enum.map(fnid->start_childMyApp.OrderFlow,%{order_id: id},id: idend)|>Enum.map(&await_child/1)endend

Observer

The optional Continuum.Observer LiveView lists runs, renders the journal event timeline per run, and includes an operational health panel for partitions, workflow versions, durable wakes, timers, leases, activities, and signals. It exposes operator actions for cancelling a run, sending a signal, and previewing fenced repairs. It is mounted from a host Phoenix router and ships no authentication of its own — wrap it in your existing admin pipeline.

Continuum Observer runs index

importContinuum.Observer.Routerscope"/admin"dopipe_through[:browser,:authenticate_admin]continuum_observer"/continuum",instance: :myapp_continuumend

To see the UI locally, run the crash demo's read-only Observer node:

docker compose up -d
mix continuum.demo --observer # http://localhost:4000/continuum

Leave it running in one terminal and drive mix continuum.demo / mix continuum.demo --resume in another: the run appears, stalls with a dead node's lease on it, and then completes under a different owner. That pane starts no dispatcher, so it watches rather than resuming the run itself. See guides/observer.md for production mount instructions.

Documentation

Full docs are published on HexDocs. The guides cover the entire surface:

  • Your first workflow
  • Activities, retries, and idempotency · Oban activity executor
  • Determinism rules and replay drift
  • Sagas and compensation · Child workflows · Long-running workflows
  • Patching workflows · Workflow versioning
  • Multi-instance Continuum · Clustering · Namespaces
  • Search attributes and structured queries
  • Operations · Auditing · Observer · Observability (OpenTelemetry) · Snapshots

See examples/continuum_example_orders for a Phoenix app exercising activity → signal/timeout → compensation, parent/child batches, continue_as_new, per-workflow snapshots, namespaces, the Observer, and OpenTelemetry.

Upgrading? See the migration guides.

Status

Continuum is v0.8.1 (pre-1.0). The durable engine, determinism enforcement, workflow composition, observability, and clustering surface are implemented and covered by tests, including crash-resume, lease-fencing races, and property-based replay. APIs may still change before 1.0 — pin to a specific 0.x in production. See CHANGELOG.md for release history.

Development

A docker-compose.yml brings up Postgres for local development and tests.

mix deps.get
docker compose up -d # Postgres on localhost:5433
mix compile --warnings-as-errors
mix test# unit + integration suite
mix test.cluster # real :peer cluster tests (run separately)
mix format
mix continuum.demo --help # the crash-recovery demo

License

Copyright 2026 The Continuum Authors. (yyeger)

Licensed under the Apache License, Version 2.0.

About

OTP-native durable execution engine for Elixir.

Resources

Stars

67 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Continuum

English | 简体中文

CIHex.pmDocumentationLicense

Continuum lets an Elixir function survive your application crashing halfway through it.

defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total)# ─── kill -9 the entire VM right here ───{:ok,shipment}=activityFulfillment.ship(id){:ok,%{charge: charge,shipment: shipment}}end

Kill the node on that middle line and nothing is lost and nothing is repeated. The process is gone; the run is not. A new VM picks the run up, executes run/1 again from the top, replays the charge out of the journal instead of calling the payment gateway a second time, and carries on into ship.

Continuum is a durable execution engine — Temporal's programming model, but OTP-native and Postgres-backed. No separate cluster service, no paid SaaS dependency, no polyglot SDK. It lives in your application's supervision tree and uses the database you already run.

See it happen

mix continuum.demo is not a diagram. It runs that workflow against a real Postgres, and then really kills the BEAM.

Continuum surviving a hard crash mid-workflow

What you just watched, in order:

  1. Start a checkout run.
  2. ChargeCard executes and commits its result to the journal.
  3. :erlang.halt/1 — no graceful shutdown, no terminate/2, no cleanup.
  4. Start a brand new VM.
  5. Boot recovery and the dispatcher find a run leased by a node that no longer exists.
  6. The workflow body runs again from its first line — and the charge does not happen again.
  7. ShipOrder runs for the first time, and the run completes.
docker compose up -d # Postgres on localhost:5433
mix continuum.demo # phase 1: charge the card, then kill the VM
mix continuum.demo --resume # phase 2: replay, ship, never re-charge
The same demo as plain text, if your client blocks GIFs
$ mix continuum.demo
── phase 1 — a checkout that dies halfway through ────────────────────
[demo] starting checkout for order #123 (4200 cents)
[continuum] run ac7e3425 started
[workflow] checkout started
[continuum] activity scheduled: ContinuumDemo.ChargeCard.run
[continuum] run ac7e3425 suspended — waiting on durable work
[continuum] activity completed: ContinuumDemo.ChargeCard.run
[workflow] card charged pay_5b1a7dbe
[demo] *** KILLING THE BEAM (erlang:halt/1, no shutdown, no cleanup) ***
[demo] the shipment has not been scheduled — the charge is already journaled
$ mix continuum.demo --resume
── phase 2 — a brand new VM picks the run back up ────────────────────
[continuum] found run ac7e3425 in state=suspended, leased by a node that no longer exists
── continuum_events for run ac7e3425 ─────────────────────────────────
0 18:09:44.495 workflow_log %{message: "checkout started", ...}
1 18:09:44.498 activity_scheduled %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
2 18:09:45.488 activity_completed %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
3 18:09:45.505 workflow_log %{message: "card charged pay_5b1a7dbe", ...}
[demo] the charge is already journaled, so replay will not re-run it
[continuum] dispatcher claimed 1 orphaned run(s) — lease had expired
[continuum] run ac7e3425 resumed — replaying its journal from event 0
[continuum] activity scheduled: ContinuumDemo.ShipOrder.run
[continuum] activity completed: ContinuumDemo.ShipOrder.run
[workflow] order shipped ship_c66c1cbb
[continuum] run ac7e3425 completed
[demo] this VM took 1.2s to finish someone else's work
── verdict ───────────────────────────────────────────────────────────
✓ card charged exactly once — 1 CHARGE line(s) in the ledger
✓ order shipped exactly once — 1 SHIP line(s) in the ledger
✓ replay stayed silent — this VM printed 1 workflow log line, not the 3 in the journal
✓ the whole function body ran again, harmlessly — run/1 executed twice;
its side effects executed once each
The function survived the VM dying halfway through it.

The demo's activities append to tmp/continuum_demo/ledger.log, which stands in for the outside world. That file, not the log lines, is the actual claim:

2026-08-26T18:09:45.486Z CHARGE order=123 payment=pay_5b1a7dbe cents=4200
2026-08-26T18:10:07.132Z SHIP order=123 shipment=ship_c66c1cbb

One CHARGE. ChargeCard deliberately declares no idempotency key, so nothing deduplicated a second call to the gateway — there was no second call.

Run mix continuum.demo --observer in a second terminal to watch the same journal fill in through the Observer LiveView while it happens, and mix continuum.demo --help for the rest. The workflow, the activities, and the kill switch are about 150 lines in dev/demo/.

Why this works

run/1 is a pure function of its journal. Every effect — activity, await signal, timer, Continuum.now/0, even Continuum.log/2 — goes through one bridge that either replays the recorded result sitting at the current cursor, or suspends the run to go produce a new one. Re-executing the body from the top is therefore free of side effects right up to the point where history ends.

That only holds if the code between effects is deterministic, so Continuum does not ask you to be careful about it. A compile-time AST scanner rejects DateTime.utc_now/0, :rand.uniform/0, :ets.*, send/2, File.*, Logger.* and friends inside workflow code, each with a remediation hint naming the replacement. Every cursor position also carries a structured identity ({kind, module, function, line, hash, ordinal}), so editing a workflow under a run that is mid-flight raises a loud Continuum.ReplayDriftError instead of silently taking a different branch than the one that was journaled.

Postgres is the only moving part — journal, lease store, timer wheel, and signal bus. Every write is a compare-and-set on a fencing token, so a node you thought was dead cannot come back and write into the history of a run somebody else has already taken over.

What you get

Continuum is to durable execution what Phoenix is to web and Oban is to job queues: the obvious answer to "how do I run a multi-step business process that survives a crash?" for Elixir-first teams.

  • Straight-line code. Express orchestration as ordinary Elixir control flow — case, with, comprehensions. Effects go through activity/2, await signal, and timer; everything else is pure.
  • Deterministic replay. A run re-executes from the top on every wake. Structured cursor identity means any divergence between replay and the original execution surfaces as a loud Continuum.ReplayDriftError, never silent corruption.
  • One dependency. Postgres is the only thing you need to operate — it is the journal, the lease store, the timer wheel, and the signal bus (LISTEN/NOTIFY).
  • It's just OTP. Continuum is a supervision tree you add to your own app. Crash recovery, leasing, and back-pressure are built on processes, not an external coordinator.

Deliberately out of scope: polyglot SDKs, cross-language activities, a separate cluster service, and Kubernetes operators.

Quickstart

defmoduleMyApp.OrderFlowdouseContinuum.Workflow,version: 1defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total),retry: [max_attempts: 5,backoff: :exponential],compensate: {Payments,:refund,[id]}caseawaitsignal(:fraud_review,timeout: hours(24))do:approved->activityFulfillment.ship(id):rejected->compensate(charge){:error,:fraud_rejected}:timeout->activityFulfillment.ship(id)endendend
{:ok,run_id}=Continuum.start(MyApp.OrderFlow,%{order_id: "o1",items: [...]})# from anywhere — durable mailbox, survives restarts:ok=Continuum.signal(run_id,:fraud_review,:approved)# blocks via PubSub with poll fallback{:ok,%{state: :completed,result: result}}=Continuum.await(run_id,30_000)

Installation

Continuum supports Elixir 1.19 and 1.20. CI exercises the compatibility endpoints on Elixir 1.19 / OTP 27 / PostgreSQL 14 and Elixir 1.20 / OTP 29 / PostgreSQL 18, in addition to scheduled failure-injection lanes.

Add Continuum and a Postgres driver to your dependencies:

defdepsdo[{:continuum,"~> 0.8.1"},{:postgrex,"~> 0.19"}]end

Point Continuum at your repo:

# config/config.exsconfig:continuum,repo: MyApp.Repo,journal: Continuum.Runtime.Journal.Postgres

Generate and run the migration:

mix continuum.gen.migration --repo MyApp.Repo
mix ecto.migrate

Add Continuum's runtime children to your supervision tree, after your repo:

defstart(_type,_args)dochildren=[MyApp.Repo,{Phoenix.PubSub,name: MyApp.PubSub}]++Continuum.children()++[MyAppWeb.Endpoint]Supervisor.start_link(children,strategy: :one_for_one,name: MyApp.Supervisor)end

Features

Determinism by construction

  • Workflow code is pure-by-construction and re-executed top-to-bottom on every wake; only effects produce side-visible work.
  • A compile-time AST scanner rejects non-deterministic calls (DateTime.utc_now, :rand.*, :ets.*, Process.send, Kernel.apply, …) with remediation hints. Helper modules opt in via use Continuum.Pure or a config :continuum, trusted_modules: [...] allowlist.
  • Deterministic primitives — Continuum.now/0, today/0, uuid4/0, random/0, and the side_effect/1 escape hatch — capture stable cursor identity at compile time.

Durable execution

  • Postgres journal with lease + fencing-token CAS on every write. A stolen lease produces a write failure and terminates the stale engine — it never corrupts history.
  • Activity execution through the built-in worker pool by default, or an optional Continuum.Oban executor for teams that already operate Oban. Continuum keeps retry/timeout policy, idempotency, and fencing-token commits in its own durable task table either way.
  • Durable timers and signals over pg_notify/LISTEN. await signal(name, timeout: ms) resolves the signal/timeout race deterministically.
  • Crash survival. Kill the engine pid mid-flight; the dispatcher re-leases the run and replay completes from the journaled history. Boot-time recovery rescues orphaned runs, tasks, and timers without stealing live remote leases.
  • Cross-run idempotency keyed on (activity_module, idempotency_key), so activities are exactly-once-ish across runs.

Workflow composition

  • Sagas / compensation — attach compensate: to an activity, then compensate/1 or compensate_all/0 to roll back completed work in deterministic LIFO (or parallel) order.
  • Parent/child workflowsawait child Mod.run(input), start_child/3, and await_child/1 for durable fan-out/fan-in.
  • continue_as_new/1 — complete the current run and start a successor with fresh history for long-running loops.
  • Workflow versioning — journaled Continuum.patched?/1 markers for safe in-place edits, and content-addressed (workflow, version_hash) dispatch that leaves a run suspended when code is missing rather than replaying through changed logic. A node with the matching version can safely resume it later.

Operations & observability

  • Continuum.Observer — an optional Phoenix LiveView with a runs index, a decoded per-run event timeline, and operator actions for cancelling a run and injecting a signal.
  • Continuum.OpenTelemetry — an opt-in bridge that turns Continuum telemetry into run_attempt/activity_attempt spans, linked back through a persisted W3C traceparent.
  • 24+ documented telemetry events under the [:continuum, …] prefix.
  • Operator tooling — monthly-partitioned events, opt-in history snapshots, the read-only mix continuum.audit, and dry-run-by-default cleanup tasks.

Multi-tenancy & clustering

  • Named multi-instance runtimes via Continuum.children(name:, repo:), each bound to its own Ecto repo.
  • Namespaces — a soft tenant boundary for list/query; single-run operations stay keyed by global run_id.
  • Search attributes and structured queriesattributes: / Continuum.set_attributes/3 plus Continuum.list_runs/1,2.
  • Cluster-aware wake routing over :pg for cross-node wakeups. The Postgres lease and fencing token remain the sole authority for writes.

Testing

Continuum.Test provides an in-memory journal for fast unit tests, Postgres helpers for integration tests, signal/timer injection, golden-history replay, and an opt-in paranoid re-replay mode that catches divergence.

Parent/child example

defmoduleMyApp.BatchFlowdouseContinuum.Workflow,version: 1defrun(%{order_ids: ids})doids|>Enum.map(fnid->start_childMyApp.OrderFlow,%{order_id: id},id: idend)|>Enum.map(&await_child/1)endend

Observer

The optional Continuum.Observer LiveView lists runs, renders the journal event timeline per run, and includes an operational health panel for partitions, workflow versions, durable wakes, timers, leases, activities, and signals. It exposes operator actions for cancelling a run, sending a signal, and previewing fenced repairs. It is mounted from a host Phoenix router and ships no authentication of its own — wrap it in your existing admin pipeline.

Continuum Observer runs index

importContinuum.Observer.Routerscope"/admin"dopipe_through[:browser,:authenticate_admin]continuum_observer"/continuum",instance: :myapp_continuumend

To see the UI locally, run the crash demo's read-only Observer node:

docker compose up -d
mix continuum.demo --observer # http://localhost:4000/continuum

Leave it running in one terminal and drive mix continuum.demo / mix continuum.demo --resume in another: the run appears, stalls with a dead node's lease on it, and then completes under a different owner. That pane starts no dispatcher, so it watches rather than resuming the run itself. See guides/observer.md for production mount instructions.

Documentation

Full docs are published on HexDocs. The guides cover the entire surface:

  • Your first workflow
  • Activities, retries, and idempotency · Oban activity executor
  • Determinism rules and replay drift
  • Sagas and compensation · Child workflows · Long-running workflows
  • Patching workflows · Workflow versioning
  • Multi-instance Continuum · Clustering · Namespaces
  • Search attributes and structured queries
  • Operations · Auditing · Observer · Observability (OpenTelemetry) · Snapshots

See examples/continuum_example_orders for a Phoenix app exercising activity → signal/timeout → compensation, parent/child batches, continue_as_new, per-workflow snapshots, namespaces, the Observer, and OpenTelemetry.

Upgrading? See the migration guides.

Status

Continuum is v0.8.1 (pre-1.0). The durable engine, determinism enforcement, workflow composition, observability, and clustering surface are implemented and covered by tests, including crash-resume, lease-fencing races, and property-based replay. APIs may still change before 1.0 — pin to a specific 0.x in production. See CHANGELOG.md for release history.

Development

A docker-compose.yml brings up Postgres for local development and tests.

mix deps.get
docker compose up -d # Postgres on localhost:5433
mix compile --warnings-as-errors
mix test# unit + integration suite
mix test.cluster # real :peer cluster tests (run separately)
mix format
mix continuum.demo --help # the crash-recovery demo

License

Copyright 2026 The Continuum Authors. (yyeger)

Licensed under the Apache License, Version 2.0.

About

OTP-native durable execution engine for Elixir.

Resources

Stars

67 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Continuum

English | 简体中文

CIHex.pmDocumentationLicense

Continuum lets an Elixir function survive your application crashing halfway through it.

defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total)# ─── kill -9 the entire VM right here ───{:ok,shipment}=activityFulfillment.ship(id){:ok,%{charge: charge,shipment: shipment}}end

Kill the node on that middle line and nothing is lost and nothing is repeated. The process is gone; the run is not. A new VM picks the run up, executes run/1 again from the top, replays the charge out of the journal instead of calling the payment gateway a second time, and carries on into ship.

Continuum is a durable execution engine — Temporal's programming model, but OTP-native and Postgres-backed. No separate cluster service, no paid SaaS dependency, no polyglot SDK. It lives in your application's supervision tree and uses the database you already run.

See it happen

mix continuum.demo is not a diagram. It runs that workflow against a real Postgres, and then really kills the BEAM.

Continuum surviving a hard crash mid-workflow

What you just watched, in order:

  1. Start a checkout run.
  2. ChargeCard executes and commits its result to the journal.
  3. :erlang.halt/1 — no graceful shutdown, no terminate/2, no cleanup.
  4. Start a brand new VM.
  5. Boot recovery and the dispatcher find a run leased by a node that no longer exists.
  6. The workflow body runs again from its first line — and the charge does not happen again.
  7. ShipOrder runs for the first time, and the run completes.
docker compose up -d # Postgres on localhost:5433
mix continuum.demo # phase 1: charge the card, then kill the VM
mix continuum.demo --resume # phase 2: replay, ship, never re-charge
The same demo as plain text, if your client blocks GIFs
$ mix continuum.demo
── phase 1 — a checkout that dies halfway through ────────────────────
[demo] starting checkout for order #123 (4200 cents)
[continuum] run ac7e3425 started
[workflow] checkout started
[continuum] activity scheduled: ContinuumDemo.ChargeCard.run
[continuum] run ac7e3425 suspended — waiting on durable work
[continuum] activity completed: ContinuumDemo.ChargeCard.run
[workflow] card charged pay_5b1a7dbe
[demo] *** KILLING THE BEAM (erlang:halt/1, no shutdown, no cleanup) ***
[demo] the shipment has not been scheduled — the charge is already journaled
$ mix continuum.demo --resume
── phase 2 — a brand new VM picks the run back up ────────────────────
[continuum] found run ac7e3425 in state=suspended, leased by a node that no longer exists
── continuum_events for run ac7e3425 ─────────────────────────────────
0 18:09:44.495 workflow_log %{message: "checkout started", ...}
1 18:09:44.498 activity_scheduled %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
2 18:09:45.488 activity_completed %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
3 18:09:45.505 workflow_log %{message: "card charged pay_5b1a7dbe", ...}
[demo] the charge is already journaled, so replay will not re-run it
[continuum] dispatcher claimed 1 orphaned run(s) — lease had expired
[continuum] run ac7e3425 resumed — replaying its journal from event 0
[continuum] activity scheduled: ContinuumDemo.ShipOrder.run
[continuum] activity completed: ContinuumDemo.ShipOrder.run
[workflow] order shipped ship_c66c1cbb
[continuum] run ac7e3425 completed
[demo] this VM took 1.2s to finish someone else's work
── verdict ───────────────────────────────────────────────────────────
✓ card charged exactly once — 1 CHARGE line(s) in the ledger
✓ order shipped exactly once — 1 SHIP line(s) in the ledger
✓ replay stayed silent — this VM printed 1 workflow log line, not the 3 in the journal
✓ the whole function body ran again, harmlessly — run/1 executed twice;
its side effects executed once each
The function survived the VM dying halfway through it.

The demo's activities append to tmp/continuum_demo/ledger.log, which stands in for the outside world. That file, not the log lines, is the actual claim:

2026-08-26T18:09:45.486Z CHARGE order=123 payment=pay_5b1a7dbe cents=4200
2026-08-26T18:10:07.132Z SHIP order=123 shipment=ship_c66c1cbb

One CHARGE. ChargeCard deliberately declares no idempotency key, so nothing deduplicated a second call to the gateway — there was no second call.

Run mix continuum.demo --observer in a second terminal to watch the same journal fill in through the Observer LiveView while it happens, and mix continuum.demo --help for the rest. The workflow, the activities, and the kill switch are about 150 lines in dev/demo/.

Why this works

run/1 is a pure function of its journal. Every effect — activity, await signal, timer, Continuum.now/0, even Continuum.log/2 — goes through one bridge that either replays the recorded result sitting at the current cursor, or suspends the run to go produce a new one. Re-executing the body from the top is therefore free of side effects right up to the point where history ends.

That only holds if the code between effects is deterministic, so Continuum does not ask you to be careful about it. A compile-time AST scanner rejects DateTime.utc_now/0, :rand.uniform/0, :ets.*, send/2, File.*, Logger.* and friends inside workflow code, each with a remediation hint naming the replacement. Every cursor position also carries a structured identity ({kind, module, function, line, hash, ordinal}), so editing a workflow under a run that is mid-flight raises a loud Continuum.ReplayDriftError instead of silently taking a different branch than the one that was journaled.

Postgres is the only moving part — journal, lease store, timer wheel, and signal bus. Every write is a compare-and-set on a fencing token, so a node you thought was dead cannot come back and write into the history of a run somebody else has already taken over.

What you get

Continuum is to durable execution what Phoenix is to web and Oban is to job queues: the obvious answer to "how do I run a multi-step business process that survives a crash?" for Elixir-first teams.

  • Straight-line code. Express orchestration as ordinary Elixir control flow — case, with, comprehensions. Effects go through activity/2, await signal, and timer; everything else is pure.
  • Deterministic replay. A run re-executes from the top on every wake. Structured cursor identity means any divergence between replay and the original execution surfaces as a loud Continuum.ReplayDriftError, never silent corruption.
  • One dependency. Postgres is the only thing you need to operate — it is the journal, the lease store, the timer wheel, and the signal bus (LISTEN/NOTIFY).
  • It's just OTP. Continuum is a supervision tree you add to your own app. Crash recovery, leasing, and back-pressure are built on processes, not an external coordinator.

Deliberately out of scope: polyglot SDKs, cross-language activities, a separate cluster service, and Kubernetes operators.

Quickstart

defmoduleMyApp.OrderFlowdouseContinuum.Workflow,version: 1defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total),retry: [max_attempts: 5,backoff: :exponential],compensate: {Payments,:refund,[id]}caseawaitsignal(:fraud_review,timeout: hours(24))do:approved->activityFulfillment.ship(id):rejected->compensate(charge){:error,:fraud_rejected}:timeout->activityFulfillment.ship(id)endendend
{:ok,run_id}=Continuum.start(MyApp.OrderFlow,%{order_id: "o1",items: [...]})# from anywhere — durable mailbox, survives restarts:ok=Continuum.signal(run_id,:fraud_review,:approved)# blocks via PubSub with poll fallback{:ok,%{state: :completed,result: result}}=Continuum.await(run_id,30_000)

Installation

Continuum supports Elixir 1.19 and 1.20. CI exercises the compatibility endpoints on Elixir 1.19 / OTP 27 / PostgreSQL 14 and Elixir 1.20 / OTP 29 / PostgreSQL 18, in addition to scheduled failure-injection lanes.

Add Continuum and a Postgres driver to your dependencies:

defdepsdo[{:continuum,"~> 0.8.1"},{:postgrex,"~> 0.19"}]end

Point Continuum at your repo:

# config/config.exsconfig:continuum,repo: MyApp.Repo,journal: Continuum.Runtime.Journal.Postgres

Generate and run the migration:

mix continuum.gen.migration --repo MyApp.Repo
mix ecto.migrate

Add Continuum's runtime children to your supervision tree, after your repo:

defstart(_type,_args)dochildren=[MyApp.Repo,{Phoenix.PubSub,name: MyApp.PubSub}]++Continuum.children()++[MyAppWeb.Endpoint]Supervisor.start_link(children,strategy: :one_for_one,name: MyApp.Supervisor)end

Features

Determinism by construction

  • Workflow code is pure-by-construction and re-executed top-to-bottom on every wake; only effects produce side-visible work.
  • A compile-time AST scanner rejects non-deterministic calls (DateTime.utc_now, :rand.*, :ets.*, Process.send, Kernel.apply, …) with remediation hints. Helper modules opt in via use Continuum.Pure or a config :continuum, trusted_modules: [...] allowlist.
  • Deterministic primitives — Continuum.now/0, today/0, uuid4/0, random/0, and the side_effect/1 escape hatch — capture stable cursor identity at compile time.

Durable execution

  • Postgres journal with lease + fencing-token CAS on every write. A stolen lease produces a write failure and terminates the stale engine — it never corrupts history.
  • Activity execution through the built-in worker pool by default, or an optional Continuum.Oban executor for teams that already operate Oban. Continuum keeps retry/timeout policy, idempotency, and fencing-token commits in its own durable task table either way.
  • Durable timers and signals over pg_notify/LISTEN. await signal(name, timeout: ms) resolves the signal/timeout race deterministically.
  • Crash survival. Kill the engine pid mid-flight; the dispatcher re-leases the run and replay completes from the journaled history. Boot-time recovery rescues orphaned runs, tasks, and timers without stealing live remote leases.
  • Cross-run idempotency keyed on (activity_module, idempotency_key), so activities are exactly-once-ish across runs.

Workflow composition

  • Sagas / compensation — attach compensate: to an activity, then compensate/1 or compensate_all/0 to roll back completed work in deterministic LIFO (or parallel) order.
  • Parent/child workflowsawait child Mod.run(input), start_child/3, and await_child/1 for durable fan-out/fan-in.
  • continue_as_new/1 — complete the current run and start a successor with fresh history for long-running loops.
  • Workflow versioning — journaled Continuum.patched?/1 markers for safe in-place edits, and content-addressed (workflow, version_hash) dispatch that leaves a run suspended when code is missing rather than replaying through changed logic. A node with the matching version can safely resume it later.

Operations & observability

  • Continuum.Observer — an optional Phoenix LiveView with a runs index, a decoded per-run event timeline, and operator actions for cancelling a run and injecting a signal.
  • Continuum.OpenTelemetry — an opt-in bridge that turns Continuum telemetry into run_attempt/activity_attempt spans, linked back through a persisted W3C traceparent.
  • 24+ documented telemetry events under the [:continuum, …] prefix.
  • Operator tooling — monthly-partitioned events, opt-in history snapshots, the read-only mix continuum.audit, and dry-run-by-default cleanup tasks.

Multi-tenancy & clustering

  • Named multi-instance runtimes via Continuum.children(name:, repo:), each bound to its own Ecto repo.
  • Namespaces — a soft tenant boundary for list/query; single-run operations stay keyed by global run_id.
  • Search attributes and structured queriesattributes: / Continuum.set_attributes/3 plus Continuum.list_runs/1,2.
  • Cluster-aware wake routing over :pg for cross-node wakeups. The Postgres lease and fencing token remain the sole authority for writes.

Testing

Continuum.Test provides an in-memory journal for fast unit tests, Postgres helpers for integration tests, signal/timer injection, golden-history replay, and an opt-in paranoid re-replay mode that catches divergence.

Parent/child example

defmoduleMyApp.BatchFlowdouseContinuum.Workflow,version: 1defrun(%{order_ids: ids})doids|>Enum.map(fnid->start_childMyApp.OrderFlow,%{order_id: id},id: idend)|>Enum.map(&await_child/1)endend

Observer

The optional Continuum.Observer LiveView lists runs, renders the journal event timeline per run, and includes an operational health panel for partitions, workflow versions, durable wakes, timers, leases, activities, and signals. It exposes operator actions for cancelling a run, sending a signal, and previewing fenced repairs. It is mounted from a host Phoenix router and ships no authentication of its own — wrap it in your existing admin pipeline.

Continuum Observer runs index

importContinuum.Observer.Routerscope"/admin"dopipe_through[:browser,:authenticate_admin]continuum_observer"/continuum",instance: :myapp_continuumend

To see the UI locally, run the crash demo's read-only Observer node:

docker compose up -d
mix continuum.demo --observer # http://localhost:4000/continuum

Leave it running in one terminal and drive mix continuum.demo / mix continuum.demo --resume in another: the run appears, stalls with a dead node's lease on it, and then completes under a different owner. That pane starts no dispatcher, so it watches rather than resuming the run itself. See guides/observer.md for production mount instructions.

Documentation

Full docs are published on HexDocs. The guides cover the entire surface:

  • Your first workflow
  • Activities, retries, and idempotency · Oban activity executor
  • Determinism rules and replay drift
  • Sagas and compensation · Child workflows · Long-running workflows
  • Patching workflows · Workflow versioning
  • Multi-instance Continuum · Clustering · Namespaces
  • Search attributes and structured queries
  • Operations · Auditing · Observer · Observability (OpenTelemetry) · Snapshots

See examples/continuum_example_orders for a Phoenix app exercising activity → signal/timeout → compensation, parent/child batches, continue_as_new, per-workflow snapshots, namespaces, the Observer, and OpenTelemetry.

Upgrading? See the migration guides.

Status

Continuum is v0.8.1 (pre-1.0). The durable engine, determinism enforcement, workflow composition, observability, and clustering surface are implemented and covered by tests, including crash-resume, lease-fencing races, and property-based replay. APIs may still change before 1.0 — pin to a specific 0.x in production. See CHANGELOG.md for release history.

Development

A docker-compose.yml brings up Postgres for local development and tests.

mix deps.get
docker compose up -d # Postgres on localhost:5433
mix compile --warnings-as-errors
mix test# unit + integration suite
mix test.cluster # real :peer cluster tests (run separately)
mix format
mix continuum.demo --help # the crash-recovery demo

License

Copyright 2026 The Continuum Authors. (yyeger)

Licensed under the Apache License, Version 2.0.

About

OTP-native durable execution engine for Elixir.

Resources

Stars

67 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

Continuum

English | 简体中文

CIHex.pmDocumentationLicense

Continuum lets an Elixir function survive your application crashing halfway through it.

defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total)# ─── kill -9 the entire VM right here ───{:ok,shipment}=activityFulfillment.ship(id){:ok,%{charge: charge,shipment: shipment}}end

Kill the node on that middle line and nothing is lost and nothing is repeated. The process is gone; the run is not. A new VM picks the run up, executes run/1 again from the top, replays the charge out of the journal instead of calling the payment gateway a second time, and carries on into ship.

Continuum is a durable execution engine — Temporal's programming model, but OTP-native and Postgres-backed. No separate cluster service, no paid SaaS dependency, no polyglot SDK. It lives in your application's supervision tree and uses the database you already run.

See it happen

mix continuum.demo is not a diagram. It runs that workflow against a real Postgres, and then really kills the BEAM.

Continuum surviving a hard crash mid-workflow

What you just watched, in order:

  1. Start a checkout run.
  2. ChargeCard executes and commits its result to the journal.
  3. :erlang.halt/1 — no graceful shutdown, no terminate/2, no cleanup.
  4. Start a brand new VM.
  5. Boot recovery and the dispatcher find a run leased by a node that no longer exists.
  6. The workflow body runs again from its first line — and the charge does not happen again.
  7. ShipOrder runs for the first time, and the run completes.
docker compose up -d # Postgres on localhost:5433
mix continuum.demo # phase 1: charge the card, then kill the VM
mix continuum.demo --resume # phase 2: replay, ship, never re-charge
The same demo as plain text, if your client blocks GIFs
$ mix continuum.demo
── phase 1 — a checkout that dies halfway through ────────────────────
[demo] starting checkout for order #123 (4200 cents)
[continuum] run ac7e3425 started
[workflow] checkout started
[continuum] activity scheduled: ContinuumDemo.ChargeCard.run
[continuum] run ac7e3425 suspended — waiting on durable work
[continuum] activity completed: ContinuumDemo.ChargeCard.run
[workflow] card charged pay_5b1a7dbe
[demo] *** KILLING THE BEAM (erlang:halt/1, no shutdown, no cleanup) ***
[demo] the shipment has not been scheduled — the charge is already journaled
$ mix continuum.demo --resume
── phase 2 — a brand new VM picks the run back up ────────────────────
[continuum] found run ac7e3425 in state=suspended, leased by a node that no longer exists
── continuum_events for run ac7e3425 ─────────────────────────────────
0 18:09:44.495 workflow_log %{message: "checkout started", ...}
1 18:09:44.498 activity_scheduled %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
2 18:09:45.488 activity_completed %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
3 18:09:45.505 workflow_log %{message: "card charged pay_5b1a7dbe", ...}
[demo] the charge is already journaled, so replay will not re-run it
[continuum] dispatcher claimed 1 orphaned run(s) — lease had expired
[continuum] run ac7e3425 resumed — replaying its journal from event 0
[continuum] activity scheduled: ContinuumDemo.ShipOrder.run
[continuum] activity completed: ContinuumDemo.ShipOrder.run
[workflow] order shipped ship_c66c1cbb
[continuum] run ac7e3425 completed
[demo] this VM took 1.2s to finish someone else's work
── verdict ───────────────────────────────────────────────────────────
✓ card charged exactly once — 1 CHARGE line(s) in the ledger
✓ order shipped exactly once — 1 SHIP line(s) in the ledger
✓ replay stayed silent — this VM printed 1 workflow log line, not the 3 in the journal
✓ the whole function body ran again, harmlessly — run/1 executed twice;
its side effects executed once each
The function survived the VM dying halfway through it.

The demo's activities append to tmp/continuum_demo/ledger.log, which stands in for the outside world. That file, not the log lines, is the actual claim:

2026-08-26T18:09:45.486Z CHARGE order=123 payment=pay_5b1a7dbe cents=4200
2026-08-26T18:10:07.132Z SHIP order=123 shipment=ship_c66c1cbb

One CHARGE. ChargeCard deliberately declares no idempotency key, so nothing deduplicated a second call to the gateway — there was no second call.

Run mix continuum.demo --observer in a second terminal to watch the same journal fill in through the Observer LiveView while it happens, and mix continuum.demo --help for the rest. The workflow, the activities, and the kill switch are about 150 lines in dev/demo/.

Why this works

run/1 is a pure function of its journal. Every effect — activity, await signal, timer, Continuum.now/0, even Continuum.log/2 — goes through one bridge that either replays the recorded result sitting at the current cursor, or suspends the run to go produce a new one. Re-executing the body from the top is therefore free of side effects right up to the point where history ends.

That only holds if the code between effects is deterministic, so Continuum does not ask you to be careful about it. A compile-time AST scanner rejects DateTime.utc_now/0, :rand.uniform/0, :ets.*, send/2, File.*, Logger.* and friends inside workflow code, each with a remediation hint naming the replacement. Every cursor position also carries a structured identity ({kind, module, function, line, hash, ordinal}), so editing a workflow under a run that is mid-flight raises a loud Continuum.ReplayDriftError instead of silently taking a different branch than the one that was journaled.

Postgres is the only moving part — journal, lease store, timer wheel, and signal bus. Every write is a compare-and-set on a fencing token, so a node you thought was dead cannot come back and write into the history of a run somebody else has already taken over.

What you get

Continuum is to durable execution what Phoenix is to web and Oban is to job queues: the obvious answer to "how do I run a multi-step business process that survives a crash?" for Elixir-first teams.

  • Straight-line code. Express orchestration as ordinary Elixir control flow — case, with, comprehensions. Effects go through activity/2, await signal, and timer; everything else is pure.
  • Deterministic replay. A run re-executes from the top on every wake. Structured cursor identity means any divergence between replay and the original execution surfaces as a loud Continuum.ReplayDriftError, never silent corruption.
  • One dependency. Postgres is the only thing you need to operate — it is the journal, the lease store, the timer wheel, and the signal bus (LISTEN/NOTIFY).
  • It's just OTP. Continuum is a supervision tree you add to your own app. Crash recovery, leasing, and back-pressure are built on processes, not an external coordinator.

Deliberately out of scope: polyglot SDKs, cross-language activities, a separate cluster service, and Kubernetes operators.

Quickstart

defmoduleMyApp.OrderFlowdouseContinuum.Workflow,version: 1defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total),retry: [max_attempts: 5,backoff: :exponential],compensate: {Payments,:refund,[id]}caseawaitsignal(:fraud_review,timeout: hours(24))do:approved->activityFulfillment.ship(id):rejected->compensate(charge){:error,:fraud_rejected}:timeout->activityFulfillment.ship(id)endendend
{:ok,run_id}=Continuum.start(MyApp.OrderFlow,%{order_id: "o1",items: [...]})# from anywhere — durable mailbox, survives restarts:ok=Continuum.signal(run_id,:fraud_review,:approved)# blocks via PubSub with poll fallback{:ok,%{state: :completed,result: result}}=Continuum.await(run_id,30_000)

Installation

Continuum supports Elixir 1.19 and 1.20. CI exercises the compatibility endpoints on Elixir 1.19 / OTP 27 / PostgreSQL 14 and Elixir 1.20 / OTP 29 / PostgreSQL 18, in addition to scheduled failure-injection lanes.

Add Continuum and a Postgres driver to your dependencies:

defdepsdo[{:continuum,"~> 0.8.1"},{:postgrex,"~> 0.19"}]end

Point Continuum at your repo:

# config/config.exsconfig:continuum,repo: MyApp.Repo,journal: Continuum.Runtime.Journal.Postgres

Generate and run the migration:

mix continuum.gen.migration --repo MyApp.Repo
mix ecto.migrate

Add Continuum's runtime children to your supervision tree, after your repo:

defstart(_type,_args)dochildren=[MyApp.Repo,{Phoenix.PubSub,name: MyApp.PubSub}]++Continuum.children()++[MyAppWeb.Endpoint]Supervisor.start_link(children,strategy: :one_for_one,name: MyApp.Supervisor)end

Features

Determinism by construction

  • Workflow code is pure-by-construction and re-executed top-to-bottom on every wake; only effects produce side-visible work.
  • A compile-time AST scanner rejects non-deterministic calls (DateTime.utc_now, :rand.*, :ets.*, Process.send, Kernel.apply, …) with remediation hints. Helper modules opt in via use Continuum.Pure or a config :continuum, trusted_modules: [...] allowlist.
  • Deterministic primitives — Continuum.now/0, today/0, uuid4/0, random/0, and the side_effect/1 escape hatch — capture stable cursor identity at compile time.

Durable execution

  • Postgres journal with lease + fencing-token CAS on every write. A stolen lease produces a write failure and terminates the stale engine — it never corrupts history.
  • Activity execution through the built-in worker pool by default, or an optional Continuum.Oban executor for teams that already operate Oban. Continuum keeps retry/timeout policy, idempotency, and fencing-token commits in its own durable task table either way.
  • Durable timers and signals over pg_notify/LISTEN. await signal(name, timeout: ms) resolves the signal/timeout race deterministically.
  • Crash survival. Kill the engine pid mid-flight; the dispatcher re-leases the run and replay completes from the journaled history. Boot-time recovery rescues orphaned runs, tasks, and timers without stealing live remote leases.
  • Cross-run idempotency keyed on (activity_module, idempotency_key), so activities are exactly-once-ish across runs.

Workflow composition

  • Sagas / compensation — attach compensate: to an activity, then compensate/1 or compensate_all/0 to roll back completed work in deterministic LIFO (or parallel) order.
  • Parent/child workflowsawait child Mod.run(input), start_child/3, and await_child/1 for durable fan-out/fan-in.
  • continue_as_new/1 — complete the current run and start a successor with fresh history for long-running loops.
  • Workflow versioning — journaled Continuum.patched?/1 markers for safe in-place edits, and content-addressed (workflow, version_hash) dispatch that leaves a run suspended when code is missing rather than replaying through changed logic. A node with the matching version can safely resume it later.

Operations & observability

  • Continuum.Observer — an optional Phoenix LiveView with a runs index, a decoded per-run event timeline, and operator actions for cancelling a run and injecting a signal.
  • Continuum.OpenTelemetry — an opt-in bridge that turns Continuum telemetry into run_attempt/activity_attempt spans, linked back through a persisted W3C traceparent.
  • 24+ documented telemetry events under the [:continuum, …] prefix.
  • Operator tooling — monthly-partitioned events, opt-in history snapshots, the read-only mix continuum.audit, and dry-run-by-default cleanup tasks.

Multi-tenancy & clustering

  • Named multi-instance runtimes via Continuum.children(name:, repo:), each bound to its own Ecto repo.
  • Namespaces — a soft tenant boundary for list/query; single-run operations stay keyed by global run_id.
  • Search attributes and structured queriesattributes: / Continuum.set_attributes/3 plus Continuum.list_runs/1,2.
  • Cluster-aware wake routing over :pg for cross-node wakeups. The Postgres lease and fencing token remain the sole authority for writes.

Testing

Continuum.Test provides an in-memory journal for fast unit tests, Postgres helpers for integration tests, signal/timer injection, golden-history replay, and an opt-in paranoid re-replay mode that catches divergence.

Parent/child example

defmoduleMyApp.BatchFlowdouseContinuum.Workflow,version: 1defrun(%{order_ids: ids})doids|>Enum.map(fnid->start_childMyApp.OrderFlow,%{order_id: id},id: idend)|>Enum.map(&await_child/1)endend

Observer

The optional Continuum.Observer LiveView lists runs, renders the journal event timeline per run, and includes an operational health panel for partitions, workflow versions, durable wakes, timers, leases, activities, and signals. It exposes operator actions for cancelling a run, sending a signal, and previewing fenced repairs. It is mounted from a host Phoenix router and ships no authentication of its own — wrap it in your existing admin pipeline.

Continuum Observer runs index

importContinuum.Observer.Routerscope"/admin"dopipe_through[:browser,:authenticate_admin]continuum_observer"/continuum",instance: :myapp_continuumend

To see the UI locally, run the crash demo's read-only Observer node:

docker compose up -d
mix continuum.demo --observer # http://localhost:4000/continuum

Leave it running in one terminal and drive mix continuum.demo / mix continuum.demo --resume in another: the run appears, stalls with a dead node's lease on it, and then completes under a different owner. That pane starts no dispatcher, so it watches rather than resuming the run itself. See guides/observer.md for production mount instructions.

Documentation

Full docs are published on HexDocs. The guides cover the entire surface:

  • Your first workflow
  • Activities, retries, and idempotency · Oban activity executor
  • Determinism rules and replay drift
  • Sagas and compensation · Child workflows · Long-running workflows
  • Patching workflows · Workflow versioning
  • Multi-instance Continuum · Clustering · Namespaces
  • Search attributes and structured queries
  • Operations · Auditing · Observer · Observability (OpenTelemetry) · Snapshots

See examples/continuum_example_orders for a Phoenix app exercising activity → signal/timeout → compensation, parent/child batches, continue_as_new, per-workflow snapshots, namespaces, the Observer, and OpenTelemetry.

Upgrading? See the migration guides.

Status

Continuum is v0.8.1 (pre-1.0). The durable engine, determinism enforcement, workflow composition, observability, and clustering surface are implemented and covered by tests, including crash-resume, lease-fencing races, and property-based replay. APIs may still change before 1.0 — pin to a specific 0.x in production. See CHANGELOG.md for release history.

Development

A docker-compose.yml brings up Postgres for local development and tests.

mix deps.get
docker compose up -d # Postgres on localhost:5433
mix compile --warnings-as-errors
mix test# unit + integration suite
mix test.cluster # real :peer cluster tests (run separately)
mix format
mix continuum.demo --help # the crash-recovery demo

License

Copyright 2026 The Continuum Authors. (yyeger)

Licensed under the Apache License, Version 2.0.

About

OTP-native durable execution engine for Elixir.

Resources

Stars

67 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Continuum

English | 简体中文

CIHex.pmDocumentationLicense

Continuum lets an Elixir function survive your application crashing halfway through it.

defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total)# ─── kill -9 the entire VM right here ───{:ok,shipment}=activityFulfillment.ship(id){:ok,%{charge: charge,shipment: shipment}}end

Kill the node on that middle line and nothing is lost and nothing is repeated. The process is gone; the run is not. A new VM picks the run up, executes run/1 again from the top, replays the charge out of the journal instead of calling the payment gateway a second time, and carries on into ship.

Continuum is a durable execution engine — Temporal's programming model, but OTP-native and Postgres-backed. No separate cluster service, no paid SaaS dependency, no polyglot SDK. It lives in your application's supervision tree and uses the database you already run.

See it happen

mix continuum.demo is not a diagram. It runs that workflow against a real Postgres, and then really kills the BEAM.

Continuum surviving a hard crash mid-workflow

What you just watched, in order:

  1. Start a checkout run.
  2. ChargeCard executes and commits its result to the journal.
  3. :erlang.halt/1 — no graceful shutdown, no terminate/2, no cleanup.
  4. Start a brand new VM.
  5. Boot recovery and the dispatcher find a run leased by a node that no longer exists.
  6. The workflow body runs again from its first line — and the charge does not happen again.
  7. ShipOrder runs for the first time, and the run completes.
docker compose up -d # Postgres on localhost:5433
mix continuum.demo # phase 1: charge the card, then kill the VM
mix continuum.demo --resume # phase 2: replay, ship, never re-charge
The same demo as plain text, if your client blocks GIFs
$ mix continuum.demo
── phase 1 — a checkout that dies halfway through ────────────────────
[demo] starting checkout for order #123 (4200 cents)
[continuum] run ac7e3425 started
[workflow] checkout started
[continuum] activity scheduled: ContinuumDemo.ChargeCard.run
[continuum] run ac7e3425 suspended — waiting on durable work
[continuum] activity completed: ContinuumDemo.ChargeCard.run
[workflow] card charged pay_5b1a7dbe
[demo] *** KILLING THE BEAM (erlang:halt/1, no shutdown, no cleanup) ***
[demo] the shipment has not been scheduled — the charge is already journaled
$ mix continuum.demo --resume
── phase 2 — a brand new VM picks the run back up ────────────────────
[continuum] found run ac7e3425 in state=suspended, leased by a node that no longer exists
── continuum_events for run ac7e3425 ─────────────────────────────────
0 18:09:44.495 workflow_log %{message: "checkout started", ...}
1 18:09:44.498 activity_scheduled %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
2 18:09:45.488 activity_completed %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
3 18:09:45.505 workflow_log %{message: "card charged pay_5b1a7dbe", ...}
[demo] the charge is already journaled, so replay will not re-run it
[continuum] dispatcher claimed 1 orphaned run(s) — lease had expired
[continuum] run ac7e3425 resumed — replaying its journal from event 0
[continuum] activity scheduled: ContinuumDemo.ShipOrder.run
[continuum] activity completed: ContinuumDemo.ShipOrder.run
[workflow] order shipped ship_c66c1cbb
[continuum] run ac7e3425 completed
[demo] this VM took 1.2s to finish someone else's work
── verdict ───────────────────────────────────────────────────────────
✓ card charged exactly once — 1 CHARGE line(s) in the ledger
✓ order shipped exactly once — 1 SHIP line(s) in the ledger
✓ replay stayed silent — this VM printed 1 workflow log line, not the 3 in the journal
✓ the whole function body ran again, harmlessly — run/1 executed twice;
its side effects executed once each
The function survived the VM dying halfway through it.

The demo's activities append to tmp/continuum_demo/ledger.log, which stands in for the outside world. That file, not the log lines, is the actual claim:

2026-08-26T18:09:45.486Z CHARGE order=123 payment=pay_5b1a7dbe cents=4200
2026-08-26T18:10:07.132Z SHIP order=123 shipment=ship_c66c1cbb

One CHARGE. ChargeCard deliberately declares no idempotency key, so nothing deduplicated a second call to the gateway — there was no second call.

Run mix continuum.demo --observer in a second terminal to watch the same journal fill in through the Observer LiveView while it happens, and mix continuum.demo --help for the rest. The workflow, the activities, and the kill switch are about 150 lines in dev/demo/.

Why this works

run/1 is a pure function of its journal. Every effect — activity, await signal, timer, Continuum.now/0, even Continuum.log/2 — goes through one bridge that either replays the recorded result sitting at the current cursor, or suspends the run to go produce a new one. Re-executing the body from the top is therefore free of side effects right up to the point where history ends.

That only holds if the code between effects is deterministic, so Continuum does not ask you to be careful about it. A compile-time AST scanner rejects DateTime.utc_now/0, :rand.uniform/0, :ets.*, send/2, File.*, Logger.* and friends inside workflow code, each with a remediation hint naming the replacement. Every cursor position also carries a structured identity ({kind, module, function, line, hash, ordinal}), so editing a workflow under a run that is mid-flight raises a loud Continuum.ReplayDriftError instead of silently taking a different branch than the one that was journaled.

Postgres is the only moving part — journal, lease store, timer wheel, and signal bus. Every write is a compare-and-set on a fencing token, so a node you thought was dead cannot come back and write into the history of a run somebody else has already taken over.

What you get

Continuum is to durable execution what Phoenix is to web and Oban is to job queues: the obvious answer to "how do I run a multi-step business process that survives a crash?" for Elixir-first teams.

  • Straight-line code. Express orchestration as ordinary Elixir control flow — case, with, comprehensions. Effects go through activity/2, await signal, and timer; everything else is pure.
  • Deterministic replay. A run re-executes from the top on every wake. Structured cursor identity means any divergence between replay and the original execution surfaces as a loud Continuum.ReplayDriftError, never silent corruption.
  • One dependency. Postgres is the only thing you need to operate — it is the journal, the lease store, the timer wheel, and the signal bus (LISTEN/NOTIFY).
  • It's just OTP. Continuum is a supervision tree you add to your own app. Crash recovery, leasing, and back-pressure are built on processes, not an external coordinator.

Deliberately out of scope: polyglot SDKs, cross-language activities, a separate cluster service, and Kubernetes operators.

Quickstart

defmoduleMyApp.OrderFlowdouseContinuum.Workflow,version: 1defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total),retry: [max_attempts: 5,backoff: :exponential],compensate: {Payments,:refund,[id]}caseawaitsignal(:fraud_review,timeout: hours(24))do:approved->activityFulfillment.ship(id):rejected->compensate(charge){:error,:fraud_rejected}:timeout->activityFulfillment.ship(id)endendend
{:ok,run_id}=Continuum.start(MyApp.OrderFlow,%{order_id: "o1",items: [...]})# from anywhere — durable mailbox, survives restarts:ok=Continuum.signal(run_id,:fraud_review,:approved)# blocks via PubSub with poll fallback{:ok,%{state: :completed,result: result}}=Continuum.await(run_id,30_000)

Installation

Continuum supports Elixir 1.19 and 1.20. CI exercises the compatibility endpoints on Elixir 1.19 / OTP 27 / PostgreSQL 14 and Elixir 1.20 / OTP 29 / PostgreSQL 18, in addition to scheduled failure-injection lanes.

Add Continuum and a Postgres driver to your dependencies:

defdepsdo[{:continuum,"~> 0.8.1"},{:postgrex,"~> 0.19"}]end

Point Continuum at your repo:

# config/config.exsconfig:continuum,repo: MyApp.Repo,journal: Continuum.Runtime.Journal.Postgres

Generate and run the migration:

mix continuum.gen.migration --repo MyApp.Repo
mix ecto.migrate

Add Continuum's runtime children to your supervision tree, after your repo:

defstart(_type,_args)dochildren=[MyApp.Repo,{Phoenix.PubSub,name: MyApp.PubSub}]++Continuum.children()++[MyAppWeb.Endpoint]Supervisor.start_link(children,strategy: :one_for_one,name: MyApp.Supervisor)end

Features

Determinism by construction

  • Workflow code is pure-by-construction and re-executed top-to-bottom on every wake; only effects produce side-visible work.
  • A compile-time AST scanner rejects non-deterministic calls (DateTime.utc_now, :rand.*, :ets.*, Process.send, Kernel.apply, …) with remediation hints. Helper modules opt in via use Continuum.Pure or a config :continuum, trusted_modules: [...] allowlist.
  • Deterministic primitives — Continuum.now/0, today/0, uuid4/0, random/0, and the side_effect/1 escape hatch — capture stable cursor identity at compile time.

Durable execution

  • Postgres journal with lease + fencing-token CAS on every write. A stolen lease produces a write failure and terminates the stale engine — it never corrupts history.
  • Activity execution through the built-in worker pool by default, or an optional Continuum.Oban executor for teams that already operate Oban. Continuum keeps retry/timeout policy, idempotency, and fencing-token commits in its own durable task table either way.
  • Durable timers and signals over pg_notify/LISTEN. await signal(name, timeout: ms) resolves the signal/timeout race deterministically.
  • Crash survival. Kill the engine pid mid-flight; the dispatcher re-leases the run and replay completes from the journaled history. Boot-time recovery rescues orphaned runs, tasks, and timers without stealing live remote leases.
  • Cross-run idempotency keyed on (activity_module, idempotency_key), so activities are exactly-once-ish across runs.

Workflow composition

  • Sagas / compensation — attach compensate: to an activity, then compensate/1 or compensate_all/0 to roll back completed work in deterministic LIFO (or parallel) order.
  • Parent/child workflowsawait child Mod.run(input), start_child/3, and await_child/1 for durable fan-out/fan-in.
  • continue_as_new/1 — complete the current run and start a successor with fresh history for long-running loops.
  • Workflow versioning — journaled Continuum.patched?/1 markers for safe in-place edits, and content-addressed (workflow, version_hash) dispatch that leaves a run suspended when code is missing rather than replaying through changed logic. A node with the matching version can safely resume it later.

Operations & observability

  • Continuum.Observer — an optional Phoenix LiveView with a runs index, a decoded per-run event timeline, and operator actions for cancelling a run and injecting a signal.
  • Continuum.OpenTelemetry — an opt-in bridge that turns Continuum telemetry into run_attempt/activity_attempt spans, linked back through a persisted W3C traceparent.
  • 24+ documented telemetry events under the [:continuum, …] prefix.
  • Operator tooling — monthly-partitioned events, opt-in history snapshots, the read-only mix continuum.audit, and dry-run-by-default cleanup tasks.

Multi-tenancy & clustering

  • Named multi-instance runtimes via Continuum.children(name:, repo:), each bound to its own Ecto repo.
  • Namespaces — a soft tenant boundary for list/query; single-run operations stay keyed by global run_id.
  • Search attributes and structured queriesattributes: / Continuum.set_attributes/3 plus Continuum.list_runs/1,2.
  • Cluster-aware wake routing over :pg for cross-node wakeups. The Postgres lease and fencing token remain the sole authority for writes.

Testing

Continuum.Test provides an in-memory journal for fast unit tests, Postgres helpers for integration tests, signal/timer injection, golden-history replay, and an opt-in paranoid re-replay mode that catches divergence.

Parent/child example

defmoduleMyApp.BatchFlowdouseContinuum.Workflow,version: 1defrun(%{order_ids: ids})doids|>Enum.map(fnid->start_childMyApp.OrderFlow,%{order_id: id},id: idend)|>Enum.map(&await_child/1)endend

Observer

The optional Continuum.Observer LiveView lists runs, renders the journal event timeline per run, and includes an operational health panel for partitions, workflow versions, durable wakes, timers, leases, activities, and signals. It exposes operator actions for cancelling a run, sending a signal, and previewing fenced repairs. It is mounted from a host Phoenix router and ships no authentication of its own — wrap it in your existing admin pipeline.

Continuum Observer runs index

importContinuum.Observer.Routerscope"/admin"dopipe_through[:browser,:authenticate_admin]continuum_observer"/continuum",instance: :myapp_continuumend

To see the UI locally, run the crash demo's read-only Observer node:

docker compose up -d
mix continuum.demo --observer # http://localhost:4000/continuum

Leave it running in one terminal and drive mix continuum.demo / mix continuum.demo --resume in another: the run appears, stalls with a dead node's lease on it, and then completes under a different owner. That pane starts no dispatcher, so it watches rather than resuming the run itself. See guides/observer.md for production mount instructions.

Documentation

Full docs are published on HexDocs. The guides cover the entire surface:

  • Your first workflow
  • Activities, retries, and idempotency · Oban activity executor
  • Determinism rules and replay drift
  • Sagas and compensation · Child workflows · Long-running workflows
  • Patching workflows · Workflow versioning
  • Multi-instance Continuum · Clustering · Namespaces
  • Search attributes and structured queries
  • Operations · Auditing · Observer · Observability (OpenTelemetry) · Snapshots

See examples/continuum_example_orders for a Phoenix app exercising activity → signal/timeout → compensation, parent/child batches, continue_as_new, per-workflow snapshots, namespaces, the Observer, and OpenTelemetry.

Upgrading? See the migration guides.

Status

Continuum is v0.8.1 (pre-1.0). The durable engine, determinism enforcement, workflow composition, observability, and clustering surface are implemented and covered by tests, including crash-resume, lease-fencing races, and property-based replay. APIs may still change before 1.0 — pin to a specific 0.x in production. See CHANGELOG.md for release history.

Development

A docker-compose.yml brings up Postgres for local development and tests.

mix deps.get
docker compose up -d # Postgres on localhost:5433
mix compile --warnings-as-errors
mix test# unit + integration suite
mix test.cluster # real :peer cluster tests (run separately)
mix format
mix continuum.demo --help # the crash-recovery demo

License

Copyright 2026 The Continuum Authors. (yyeger)

Licensed under the Apache License, Version 2.0.

About

OTP-native durable execution engine for Elixir.

Resources

Stars

67 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

Continuum

English | 简体中文

CIHex.pmDocumentationLicense

Continuum lets an Elixir function survive your application crashing halfway through it.

defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total)# ─── kill -9 the entire VM right here ───{:ok,shipment}=activityFulfillment.ship(id){:ok,%{charge: charge,shipment: shipment}}end

Kill the node on that middle line and nothing is lost and nothing is repeated. The process is gone; the run is not. A new VM picks the run up, executes run/1 again from the top, replays the charge out of the journal instead of calling the payment gateway a second time, and carries on into ship.

Continuum is a durable execution engine — Temporal's programming model, but OTP-native and Postgres-backed. No separate cluster service, no paid SaaS dependency, no polyglot SDK. It lives in your application's supervision tree and uses the database you already run.

See it happen

mix continuum.demo is not a diagram. It runs that workflow against a real Postgres, and then really kills the BEAM.

Continuum surviving a hard crash mid-workflow

What you just watched, in order:

  1. Start a checkout run.
  2. ChargeCard executes and commits its result to the journal.
  3. :erlang.halt/1 — no graceful shutdown, no terminate/2, no cleanup.
  4. Start a brand new VM.
  5. Boot recovery and the dispatcher find a run leased by a node that no longer exists.
  6. The workflow body runs again from its first line — and the charge does not happen again.
  7. ShipOrder runs for the first time, and the run completes.
docker compose up -d # Postgres on localhost:5433
mix continuum.demo # phase 1: charge the card, then kill the VM
mix continuum.demo --resume # phase 2: replay, ship, never re-charge
The same demo as plain text, if your client blocks GIFs
$ mix continuum.demo
── phase 1 — a checkout that dies halfway through ────────────────────
[demo] starting checkout for order #123 (4200 cents)
[continuum] run ac7e3425 started
[workflow] checkout started
[continuum] activity scheduled: ContinuumDemo.ChargeCard.run
[continuum] run ac7e3425 suspended — waiting on durable work
[continuum] activity completed: ContinuumDemo.ChargeCard.run
[workflow] card charged pay_5b1a7dbe
[demo] *** KILLING THE BEAM (erlang:halt/1, no shutdown, no cleanup) ***
[demo] the shipment has not been scheduled — the charge is already journaled
$ mix continuum.demo --resume
── phase 2 — a brand new VM picks the run back up ────────────────────
[continuum] found run ac7e3425 in state=suspended, leased by a node that no longer exists
── continuum_events for run ac7e3425 ─────────────────────────────────
0 18:09:44.495 workflow_log %{message: "checkout started", ...}
1 18:09:44.498 activity_scheduled %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
2 18:09:45.488 activity_completed %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
3 18:09:45.505 workflow_log %{message: "card charged pay_5b1a7dbe", ...}
[demo] the charge is already journaled, so replay will not re-run it
[continuum] dispatcher claimed 1 orphaned run(s) — lease had expired
[continuum] run ac7e3425 resumed — replaying its journal from event 0
[continuum] activity scheduled: ContinuumDemo.ShipOrder.run
[continuum] activity completed: ContinuumDemo.ShipOrder.run
[workflow] order shipped ship_c66c1cbb
[continuum] run ac7e3425 completed
[demo] this VM took 1.2s to finish someone else's work
── verdict ───────────────────────────────────────────────────────────
✓ card charged exactly once — 1 CHARGE line(s) in the ledger
✓ order shipped exactly once — 1 SHIP line(s) in the ledger
✓ replay stayed silent — this VM printed 1 workflow log line, not the 3 in the journal
✓ the whole function body ran again, harmlessly — run/1 executed twice;
its side effects executed once each
The function survived the VM dying halfway through it.

The demo's activities append to tmp/continuum_demo/ledger.log, which stands in for the outside world. That file, not the log lines, is the actual claim:

2026-08-26T18:09:45.486Z CHARGE order=123 payment=pay_5b1a7dbe cents=4200
2026-08-26T18:10:07.132Z SHIP order=123 shipment=ship_c66c1cbb

One CHARGE. ChargeCard deliberately declares no idempotency key, so nothing deduplicated a second call to the gateway — there was no second call.

Run mix continuum.demo --observer in a second terminal to watch the same journal fill in through the Observer LiveView while it happens, and mix continuum.demo --help for the rest. The workflow, the activities, and the kill switch are about 150 lines in dev/demo/.

Why this works

run/1 is a pure function of its journal. Every effect — activity, await signal, timer, Continuum.now/0, even Continuum.log/2 — goes through one bridge that either replays the recorded result sitting at the current cursor, or suspends the run to go produce a new one. Re-executing the body from the top is therefore free of side effects right up to the point where history ends.

That only holds if the code between effects is deterministic, so Continuum does not ask you to be careful about it. A compile-time AST scanner rejects DateTime.utc_now/0, :rand.uniform/0, :ets.*, send/2, File.*, Logger.* and friends inside workflow code, each with a remediation hint naming the replacement. Every cursor position also carries a structured identity ({kind, module, function, line, hash, ordinal}), so editing a workflow under a run that is mid-flight raises a loud Continuum.ReplayDriftError instead of silently taking a different branch than the one that was journaled.

Postgres is the only moving part — journal, lease store, timer wheel, and signal bus. Every write is a compare-and-set on a fencing token, so a node you thought was dead cannot come back and write into the history of a run somebody else has already taken over.

What you get

Continuum is to durable execution what Phoenix is to web and Oban is to job queues: the obvious answer to "how do I run a multi-step business process that survives a crash?" for Elixir-first teams.

  • Straight-line code. Express orchestration as ordinary Elixir control flow — case, with, comprehensions. Effects go through activity/2, await signal, and timer; everything else is pure.
  • Deterministic replay. A run re-executes from the top on every wake. Structured cursor identity means any divergence between replay and the original execution surfaces as a loud Continuum.ReplayDriftError, never silent corruption.
  • One dependency. Postgres is the only thing you need to operate — it is the journal, the lease store, the timer wheel, and the signal bus (LISTEN/NOTIFY).
  • It's just OTP. Continuum is a supervision tree you add to your own app. Crash recovery, leasing, and back-pressure are built on processes, not an external coordinator.

Deliberately out of scope: polyglot SDKs, cross-language activities, a separate cluster service, and Kubernetes operators.

Quickstart

defmoduleMyApp.OrderFlowdouseContinuum.Workflow,version: 1defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total),retry: [max_attempts: 5,backoff: :exponential],compensate: {Payments,:refund,[id]}caseawaitsignal(:fraud_review,timeout: hours(24))do:approved->activityFulfillment.ship(id):rejected->compensate(charge){:error,:fraud_rejected}:timeout->activityFulfillment.ship(id)endendend
{:ok,run_id}=Continuum.start(MyApp.OrderFlow,%{order_id: "o1",items: [...]})# from anywhere — durable mailbox, survives restarts:ok=Continuum.signal(run_id,:fraud_review,:approved)# blocks via PubSub with poll fallback{:ok,%{state: :completed,result: result}}=Continuum.await(run_id,30_000)

Installation

Continuum supports Elixir 1.19 and 1.20. CI exercises the compatibility endpoints on Elixir 1.19 / OTP 27 / PostgreSQL 14 and Elixir 1.20 / OTP 29 / PostgreSQL 18, in addition to scheduled failure-injection lanes.

Add Continuum and a Postgres driver to your dependencies:

defdepsdo[{:continuum,"~> 0.8.1"},{:postgrex,"~> 0.19"}]end

Point Continuum at your repo:

# config/config.exsconfig:continuum,repo: MyApp.Repo,journal: Continuum.Runtime.Journal.Postgres

Generate and run the migration:

mix continuum.gen.migration --repo MyApp.Repo
mix ecto.migrate

Add Continuum's runtime children to your supervision tree, after your repo:

defstart(_type,_args)dochildren=[MyApp.Repo,{Phoenix.PubSub,name: MyApp.PubSub}]++Continuum.children()++[MyAppWeb.Endpoint]Supervisor.start_link(children,strategy: :one_for_one,name: MyApp.Supervisor)end

Features

Determinism by construction

  • Workflow code is pure-by-construction and re-executed top-to-bottom on every wake; only effects produce side-visible work.
  • A compile-time AST scanner rejects non-deterministic calls (DateTime.utc_now, :rand.*, :ets.*, Process.send, Kernel.apply, …) with remediation hints. Helper modules opt in via use Continuum.Pure or a config :continuum, trusted_modules: [...] allowlist.
  • Deterministic primitives — Continuum.now/0, today/0, uuid4/0, random/0, and the side_effect/1 escape hatch — capture stable cursor identity at compile time.

Durable execution

  • Postgres journal with lease + fencing-token CAS on every write. A stolen lease produces a write failure and terminates the stale engine — it never corrupts history.
  • Activity execution through the built-in worker pool by default, or an optional Continuum.Oban executor for teams that already operate Oban. Continuum keeps retry/timeout policy, idempotency, and fencing-token commits in its own durable task table either way.
  • Durable timers and signals over pg_notify/LISTEN. await signal(name, timeout: ms) resolves the signal/timeout race deterministically.
  • Crash survival. Kill the engine pid mid-flight; the dispatcher re-leases the run and replay completes from the journaled history. Boot-time recovery rescues orphaned runs, tasks, and timers without stealing live remote leases.
  • Cross-run idempotency keyed on (activity_module, idempotency_key), so activities are exactly-once-ish across runs.

Workflow composition

  • Sagas / compensation — attach compensate: to an activity, then compensate/1 or compensate_all/0 to roll back completed work in deterministic LIFO (or parallel) order.
  • Parent/child workflowsawait child Mod.run(input), start_child/3, and await_child/1 for durable fan-out/fan-in.
  • continue_as_new/1 — complete the current run and start a successor with fresh history for long-running loops.
  • Workflow versioning — journaled Continuum.patched?/1 markers for safe in-place edits, and content-addressed (workflow, version_hash) dispatch that leaves a run suspended when code is missing rather than replaying through changed logic. A node with the matching version can safely resume it later.

Operations & observability

  • Continuum.Observer — an optional Phoenix LiveView with a runs index, a decoded per-run event timeline, and operator actions for cancelling a run and injecting a signal.
  • Continuum.OpenTelemetry — an opt-in bridge that turns Continuum telemetry into run_attempt/activity_attempt spans, linked back through a persisted W3C traceparent.
  • 24+ documented telemetry events under the [:continuum, …] prefix.
  • Operator tooling — monthly-partitioned events, opt-in history snapshots, the read-only mix continuum.audit, and dry-run-by-default cleanup tasks.

Multi-tenancy & clustering

  • Named multi-instance runtimes via Continuum.children(name:, repo:), each bound to its own Ecto repo.
  • Namespaces — a soft tenant boundary for list/query; single-run operations stay keyed by global run_id.
  • Search attributes and structured queriesattributes: / Continuum.set_attributes/3 plus Continuum.list_runs/1,2.
  • Cluster-aware wake routing over :pg for cross-node wakeups. The Postgres lease and fencing token remain the sole authority for writes.

Testing

Continuum.Test provides an in-memory journal for fast unit tests, Postgres helpers for integration tests, signal/timer injection, golden-history replay, and an opt-in paranoid re-replay mode that catches divergence.

Parent/child example

defmoduleMyApp.BatchFlowdouseContinuum.Workflow,version: 1defrun(%{order_ids: ids})doids|>Enum.map(fnid->start_childMyApp.OrderFlow,%{order_id: id},id: idend)|>Enum.map(&await_child/1)endend

Observer

The optional Continuum.Observer LiveView lists runs, renders the journal event timeline per run, and includes an operational health panel for partitions, workflow versions, durable wakes, timers, leases, activities, and signals. It exposes operator actions for cancelling a run, sending a signal, and previewing fenced repairs. It is mounted from a host Phoenix router and ships no authentication of its own — wrap it in your existing admin pipeline.

Continuum Observer runs index

importContinuum.Observer.Routerscope"/admin"dopipe_through[:browser,:authenticate_admin]continuum_observer"/continuum",instance: :myapp_continuumend

To see the UI locally, run the crash demo's read-only Observer node:

docker compose up -d
mix continuum.demo --observer # http://localhost:4000/continuum

Leave it running in one terminal and drive mix continuum.demo / mix continuum.demo --resume in another: the run appears, stalls with a dead node's lease on it, and then completes under a different owner. That pane starts no dispatcher, so it watches rather than resuming the run itself. See guides/observer.md for production mount instructions.

Documentation

Full docs are published on HexDocs. The guides cover the entire surface:

  • Your first workflow
  • Activities, retries, and idempotency · Oban activity executor
  • Determinism rules and replay drift
  • Sagas and compensation · Child workflows · Long-running workflows
  • Patching workflows · Workflow versioning
  • Multi-instance Continuum · Clustering · Namespaces
  • Search attributes and structured queries
  • Operations · Auditing · Observer · Observability (OpenTelemetry) · Snapshots

See examples/continuum_example_orders for a Phoenix app exercising activity → signal/timeout → compensation, parent/child batches, continue_as_new, per-workflow snapshots, namespaces, the Observer, and OpenTelemetry.

Upgrading? See the migration guides.

Status

Continuum is v0.8.1 (pre-1.0). The durable engine, determinism enforcement, workflow composition, observability, and clustering surface are implemented and covered by tests, including crash-resume, lease-fencing races, and property-based replay. APIs may still change before 1.0 — pin to a specific 0.x in production. See CHANGELOG.md for release history.

Development

A docker-compose.yml brings up Postgres for local development and tests.

mix deps.get
docker compose up -d # Postgres on localhost:5433
mix compile --warnings-as-errors
mix test# unit + integration suite
mix test.cluster # real :peer cluster tests (run separately)
mix format
mix continuum.demo --help # the crash-recovery demo

License

Copyright 2026 The Continuum Authors. (yyeger)

Licensed under the Apache License, Version 2.0.

About

OTP-native durable execution engine for Elixir.

Resources

Stars

67 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

Continuum

English | 简体中文

CIHex.pmDocumentationLicense

Continuum lets an Elixir function survive your application crashing halfway through it.

defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total)# ─── kill -9 the entire VM right here ───{:ok,shipment}=activityFulfillment.ship(id){:ok,%{charge: charge,shipment: shipment}}end

Kill the node on that middle line and nothing is lost and nothing is repeated. The process is gone; the run is not. A new VM picks the run up, executes run/1 again from the top, replays the charge out of the journal instead of calling the payment gateway a second time, and carries on into ship.

Continuum is a durable execution engine — Temporal's programming model, but OTP-native and Postgres-backed. No separate cluster service, no paid SaaS dependency, no polyglot SDK. It lives in your application's supervision tree and uses the database you already run.

See it happen

mix continuum.demo is not a diagram. It runs that workflow against a real Postgres, and then really kills the BEAM.

Continuum surviving a hard crash mid-workflow

What you just watched, in order:

  1. Start a checkout run.
  2. ChargeCard executes and commits its result to the journal.
  3. :erlang.halt/1 — no graceful shutdown, no terminate/2, no cleanup.
  4. Start a brand new VM.
  5. Boot recovery and the dispatcher find a run leased by a node that no longer exists.
  6. The workflow body runs again from its first line — and the charge does not happen again.
  7. ShipOrder runs for the first time, and the run completes.
docker compose up -d # Postgres on localhost:5433
mix continuum.demo # phase 1: charge the card, then kill the VM
mix continuum.demo --resume # phase 2: replay, ship, never re-charge
The same demo as plain text, if your client blocks GIFs
$ mix continuum.demo
── phase 1 — a checkout that dies halfway through ────────────────────
[demo] starting checkout for order #123 (4200 cents)
[continuum] run ac7e3425 started
[workflow] checkout started
[continuum] activity scheduled: ContinuumDemo.ChargeCard.run
[continuum] run ac7e3425 suspended — waiting on durable work
[continuum] activity completed: ContinuumDemo.ChargeCard.run
[workflow] card charged pay_5b1a7dbe
[demo] *** KILLING THE BEAM (erlang:halt/1, no shutdown, no cleanup) ***
[demo] the shipment has not been scheduled — the charge is already journaled
$ mix continuum.demo --resume
── phase 2 — a brand new VM picks the run back up ────────────────────
[continuum] found run ac7e3425 in state=suspended, leased by a node that no longer exists
── continuum_events for run ac7e3425 ─────────────────────────────────
0 18:09:44.495 workflow_log %{message: "checkout started", ...}
1 18:09:44.498 activity_scheduled %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
2 18:09:45.488 activity_completed %{mfa: {ContinuumDemo.ChargeCard, :run, ...}}
3 18:09:45.505 workflow_log %{message: "card charged pay_5b1a7dbe", ...}
[demo] the charge is already journaled, so replay will not re-run it
[continuum] dispatcher claimed 1 orphaned run(s) — lease had expired
[continuum] run ac7e3425 resumed — replaying its journal from event 0
[continuum] activity scheduled: ContinuumDemo.ShipOrder.run
[continuum] activity completed: ContinuumDemo.ShipOrder.run
[workflow] order shipped ship_c66c1cbb
[continuum] run ac7e3425 completed
[demo] this VM took 1.2s to finish someone else's work
── verdict ───────────────────────────────────────────────────────────
✓ card charged exactly once — 1 CHARGE line(s) in the ledger
✓ order shipped exactly once — 1 SHIP line(s) in the ledger
✓ replay stayed silent — this VM printed 1 workflow log line, not the 3 in the journal
✓ the whole function body ran again, harmlessly — run/1 executed twice;
its side effects executed once each
The function survived the VM dying halfway through it.

The demo's activities append to tmp/continuum_demo/ledger.log, which stands in for the outside world. That file, not the log lines, is the actual claim:

2026-08-26T18:09:45.486Z CHARGE order=123 payment=pay_5b1a7dbe cents=4200
2026-08-26T18:10:07.132Z SHIP order=123 shipment=ship_c66c1cbb

One CHARGE. ChargeCard deliberately declares no idempotency key, so nothing deduplicated a second call to the gateway — there was no second call.

Run mix continuum.demo --observer in a second terminal to watch the same journal fill in through the Observer LiveView while it happens, and mix continuum.demo --help for the rest. The workflow, the activities, and the kill switch are about 150 lines in dev/demo/.

Why this works

run/1 is a pure function of its journal. Every effect — activity, await signal, timer, Continuum.now/0, even Continuum.log/2 — goes through one bridge that either replays the recorded result sitting at the current cursor, or suspends the run to go produce a new one. Re-executing the body from the top is therefore free of side effects right up to the point where history ends.

That only holds if the code between effects is deterministic, so Continuum does not ask you to be careful about it. A compile-time AST scanner rejects DateTime.utc_now/0, :rand.uniform/0, :ets.*, send/2, File.*, Logger.* and friends inside workflow code, each with a remediation hint naming the replacement. Every cursor position also carries a structured identity ({kind, module, function, line, hash, ordinal}), so editing a workflow under a run that is mid-flight raises a loud Continuum.ReplayDriftError instead of silently taking a different branch than the one that was journaled.

Postgres is the only moving part — journal, lease store, timer wheel, and signal bus. Every write is a compare-and-set on a fencing token, so a node you thought was dead cannot come back and write into the history of a run somebody else has already taken over.

What you get

Continuum is to durable execution what Phoenix is to web and Oban is to job queues: the obvious answer to "how do I run a multi-step business process that survives a crash?" for Elixir-first teams.

  • Straight-line code. Express orchestration as ordinary Elixir control flow — case, with, comprehensions. Effects go through activity/2, await signal, and timer; everything else is pure.
  • Deterministic replay. A run re-executes from the top on every wake. Structured cursor identity means any divergence between replay and the original execution surfaces as a loud Continuum.ReplayDriftError, never silent corruption.
  • One dependency. Postgres is the only thing you need to operate — it is the journal, the lease store, the timer wheel, and the signal bus (LISTEN/NOTIFY).
  • It's just OTP. Continuum is a supervision tree you add to your own app. Crash recovery, leasing, and back-pressure are built on processes, not an external coordinator.

Deliberately out of scope: polyglot SDKs, cross-language activities, a separate cluster service, and Kubernetes operators.

Quickstart

defmoduleMyApp.OrderFlowdouseContinuum.Workflow,version: 1defrun(%{order_id: id,items: items})do{:ok,validated}=activityValidation.check(items){:ok,charge}=activityPayments.charge(id,validated.total),retry: [max_attempts: 5,backoff: :exponential],compensate: {Payments,:refund,[id]}caseawaitsignal(:fraud_review,timeout: hours(24))do:approved->activityFulfillment.ship(id):rejected->compensate(charge){:error,:fraud_rejected}:timeout->activityFulfillment.ship(id)endendend
{:ok,run_id}=Continuum.start(MyApp.OrderFlow,%{order_id: "o1",items: [...]})# from anywhere — durable mailbox, survives restarts:ok=Continuum.signal(run_id,:fraud_review,:approved)# blocks via PubSub with poll fallback{:ok,%{state: :completed,result: result}}=Continuum.await(run_id,30_000)

Installation

Continuum supports Elixir 1.19 and 1.20. CI exercises the compatibility endpoints on Elixir 1.19 / OTP 27 / PostgreSQL 14 and Elixir 1.20 / OTP 29 / PostgreSQL 18, in addition to scheduled failure-injection lanes.

Add Continuum and a Postgres driver to your dependencies:

defdepsdo[{:continuum,"~> 0.8.1"},{:postgrex,"~> 0.19"}]end

Point Continuum at your repo:

# config/config.exsconfig:continuum,repo: MyApp.Repo,journal: Continuum.Runtime.Journal.Postgres

Generate and run the migration:

mix continuum.gen.migration --repo MyApp.Repo
mix ecto.migrate

Add Continuum's runtime children to your supervision tree, after your repo:

defstart(_type,_args)dochildren=[MyApp.Repo,{Phoenix.PubSub,name: MyApp.PubSub}]++Continuum.children()++[MyAppWeb.Endpoint]Supervisor.start_link(children,strategy: :one_for_one,name: MyApp.Supervisor)end

Features

Determinism by construction

  • Workflow code is pure-by-construction and re-executed top-to-bottom on every wake; only effects produce side-visible work.
  • A compile-time AST scanner rejects non-deterministic calls (DateTime.utc_now, :rand.*, :ets.*, Process.send, Kernel.apply, …) with remediation hints. Helper modules opt in via use Continuum.Pure or a config :continuum, trusted_modules: [...] allowlist.
  • Deterministic primitives — Continuum.now/0, today/0, uuid4/0, random/0, and the side_effect/1 escape hatch — capture stable cursor identity at compile time.

Durable execution

  • Postgres journal with lease + fencing-token CAS on every write. A stolen lease produces a write failure and terminates the stale engine — it never corrupts history.
  • Activity execution through the built-in worker pool by default, or an optional Continuum.Oban executor for teams that already operate Oban. Continuum keeps retry/timeout policy, idempotency, and fencing-token commits in its own durable task table either way.
  • Durable timers and signals over pg_notify/LISTEN. await signal(name, timeout: ms) resolves the signal/timeout race deterministically.
  • Crash survival. Kill the engine pid mid-flight; the dispatcher re-leases the run and replay completes from the journaled history. Boot-time recovery rescues orphaned runs, tasks, and timers without stealing live remote leases.
  • Cross-run idempotency keyed on (activity_module, idempotency_key), so activities are exactly-once-ish across runs.

Workflow composition

  • Sagas / compensation — attach compensate: to an activity, then compensate/1 or compensate_all/0 to roll back completed work in deterministic LIFO (or parallel) order.
  • Parent/child workflowsawait child Mod.run(input), start_child/3, and await_child/1 for durable fan-out/fan-in.
  • continue_as_new/1 — complete the current run and start a successor with fresh history for long-running loops.
  • Workflow versioning — journaled Continuum.patched?/1 markers for safe in-place edits, and content-addressed (workflow, version_hash) dispatch that leaves a run suspended when code is missing rather than replaying through changed logic. A node with the matching version can safely resume it later.

Operations & observability

  • Continuum.Observer — an optional Phoenix LiveView with a runs index, a decoded per-run event timeline, and operator actions for cancelling a run and injecting a signal.
  • Continuum.OpenTelemetry — an opt-in bridge that turns Continuum telemetry into run_attempt/activity_attempt spans, linked back through a persisted W3C traceparent.
  • 24+ documented telemetry events under the [:continuum, …] prefix.
  • Operator tooling — monthly-partitioned events, opt-in history snapshots, the read-only mix continuum.audit, and dry-run-by-default cleanup tasks.

Multi-tenancy & clustering

  • Named multi-instance runtimes via Continuum.children(name:, repo:), each bound to its own Ecto repo.
  • Namespaces — a soft tenant boundary for list/query; single-run operations stay keyed by global run_id.
  • Search attributes and structured queriesattributes: / Continuum.set_attributes/3 plus Continuum.list_runs/1,2.
  • Cluster-aware wake routing over :pg for cross-node wakeups. The Postgres lease and fencing token remain the sole authority for writes.

Testing

Continuum.Test provides an in-memory journal for fast unit tests, Postgres helpers for integration tests, signal/timer injection, golden-history replay, and an opt-in paranoid re-replay mode that catches divergence.

Parent/child example

defmoduleMyApp.BatchFlowdouseContinuum.Workflow,version: 1defrun(%{order_ids: ids})doids|>Enum.map(fnid->start_childMyApp.OrderFlow,%{order_id: id},id: idend)|>Enum.map(&await_child/1)endend

Observer

The optional Continuum.Observer LiveView lists runs, renders the journal event timeline per run, and includes an operational health panel for partitions, workflow versions, durable wakes, timers, leases, activities, and signals. It exposes operator actions for cancelling a run, sending a signal, and previewing fenced repairs. It is mounted from a host Phoenix router and ships no authentication of its own — wrap it in your existing admin pipeline.

Continuum Observer runs index

importContinuum.Observer.Routerscope"/admin"dopipe_through[:browser,:authenticate_admin]continuum_observer"/continuum",instance: :myapp_continuumend

To see the UI locally, run the crash demo's read-only Observer node:

docker compose up -d
mix continuum.demo --observer # http://localhost:4000/continuum

Leave it running in one terminal and drive mix continuum.demo / mix continuum.demo --resume in another: the run appears, stalls with a dead node's lease on it, and then completes under a different owner. That pane starts no dispatcher, so it watches rather than resuming the run itself. See guides/observer.md for production mount instructions.

Documentation

Full docs are published on HexDocs. The guides cover the entire surface:

  • Your first workflow
  • Activities, retries, and idempotency · Oban activity executor
  • Determinism rules and replay drift
  • Sagas and compensation · Child workflows · Long-running workflows
  • Patching workflows · Workflow versioning
  • Multi-instance Continuum · Clustering · Namespaces
  • Search attributes and structured queries
  • Operations · Auditing · Observer · Observability (OpenTelemetry) · Snapshots

See examples/continuum_example_orders for a Phoenix app exercising activity → signal/timeout → compensation, parent/child batches, continue_as_new, per-workflow snapshots, namespaces, the Observer, and OpenTelemetry.

Upgrading? See the migration guides.

Status

Continuum is v0.8.1 (pre-1.0). The durable engine, determinism enforcement, workflow composition, observability, and clustering surface are implemented and covered by tests, including crash-resume, lease-fencing races, and property-based replay. APIs may still change before 1.0 — pin to a specific 0.x in production. See CHANGELOG.md for release history.

Development

A docker-compose.yml brings up Postgres for local development and tests.

mix deps.get
docker compose up -d # Postgres on localhost:5433
mix compile --warnings-as-errors
mix test# unit + integration suite
mix test.cluster # real :peer cluster tests (run separately)
mix format
mix continuum.demo --help # the crash-recovery demo

License

Copyright 2026 The Continuum Authors. (yyeger)

Licensed under the Apache License, Version 2.0.

About

OTP-native durable execution engine for Elixir.

Resources

Stars

67 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages