Mix.install(
[
{:ash_onetime, "~> 1.5.0"},
{:ash_postgres, "~> 2.13"}
],
consolidate_protocols: false
)A result can outlive the caller's transaction
An external effect can commit while the transaction recording its response rolls back.
Retrying blindly would duplicate it. ash_onetime reserves a durable operation key
before execution; a retry uses that same key to recover the accepted result.
This notebook's effect is a durable PostgreSQL receipt ledger. A separate Repo and connection pool commit the receipt independently of the protected action's transaction. You will observe fresh execution, stored replay, recovery after a real rollback, and a concurrent retry refused while the peer is blocked on a real database lock.
This is an executable database adapter example. An HTTP or payment adapter needs its own peer's documented idempotency and authoritative recovery API; see the external-effects guide.
Requires Elixir 1.20+, Ash 3.34.3+, and PostgreSQL 18 at
DATABASE_URL. Use a development database where you can create and remove schemas. The final cell removes this run's admission objects, receipt ledger, and invocation log.
Connect the action and ledger independently
database_url = System.fetch_env!("DATABASE_URL")
Logger.configure(level: :warning)
:ok = Application.put_env(:ash, :disable_async?, true)
:ok = Application.put_env(:ash, :default_string_length_count, :codepoints)
for repo <- [AshOnetimeDemo.Repo, AshOnetimeDemo.PeerRepo] do
:ok = Application.put_env(:ash_onetime_demo, repo, url: database_url, pool_size: 10)
end
defmodule AshOnetimeDemo.Repo do
use AshPostgres.Repo, otp_app: :ash_onetime_demo
@impl AshPostgres.Repo
def installed_extensions, do: ["ash-functions"]
@impl AshPostgres.Repo
def min_pg_version, do: %Version{major: 18, minor: 0, patch: 0}
end
defmodule AshOnetimeDemo.PeerRepo do
use Ecto.Repo, otp_app: :ash_onetime_demo, adapter: Ecto.Adapters.Postgres
end
{:ok, _} = AshOnetimeDemo.Repo.start_link()
{:ok, _} = AshOnetimeDemo.PeerRepo.start_link()Implement the ledger's execute and recover operations
The operation key is the claim UUID supplied by the library. The ledger's primary key
admits one receipt per operation; its append-only invocation log records each callback.
Each log insert commits before the receipt transaction, so a failed receipt write cannot
erase its invocation. The log exposes repeated executions even if receipt deduplication
hides their effect.
recover/3 returns :absent only after a successful authoritative lookup finds no receipt.
A failed lookup returns :unknown.
defmodule AshOnetimeDemo.PeerAdapter do
@behaviour AshOnetime.ExternalEffect
alias AshOnetimeDemo.PeerRepo
alias Ecto.Adapters.SQL
@impl true
def execute(operation_key, input, _context) do
schema = schema!(input)
value = Ash.ActionInput.get_argument(input, :value)
log_call!(schema, operation_key, "execute")
PeerRepo.transaction(fn ->
%{rows: [[stored]]} = SQL.query!(PeerRepo, """
INSERT INTO "#{schema}".external_receipts (operation_key, value)
VALUES ($1::text::uuid, $2)
ON CONFLICT (operation_key) DO UPDATE SET operation_key = EXCLUDED.operation_key
RETURNING value
""", [operation_key, value], timeout: 10_000)
stored
end, timeout: 15_000)
rescue
_ -> {:error, :outcome_unknown}
end
@impl true
def recover(operation_key, input, _context) do
schema = schema!(input)
log_call!(schema, operation_key, "recover")
case SQL.query!(PeerRepo, """
SELECT value FROM "#{schema}".external_receipts
WHERE operation_key = $1::text::uuid
""", [operation_key], timeout: 5_000).rows do
[[value]] -> {:ok, value}
[] -> :absent
end
rescue
_ -> :unknown
end
defp log_call!(schema, operation_key, kind) do
SQL.query!(PeerRepo, """
INSERT INTO "#{schema}".external_invocations (operation_key, kind)
VALUES ($1::text::uuid, $2)
""", [operation_key, kind], timeout: 5_000)
end
defp schema!(input) do
schema = input.tenant
true = is_binary(schema) and Regex.match?(~r/^ash_onetime_demo_[a-z0-9_]+$/, schema)
schema
end
end
defmodule AshOnetimeDemo.ChargeCodec do
@behaviour AshOnetime.Codec
@impl true
def format_tag, do: "charge-v1"
@impl true
def encode(value, _contract, _opts), do: {:ok, format_tag(), :erlang.term_to_binary(value)}
@impl true
def decode("charge-v1", payload, _contract, _opts), do: {:ok, :erlang.binary_to_term(payload, [:safe])}
end
defmodule AshOnetimeDemo.ChargeClassifier do
def classify(value, _context), do: {:store, value}
end
defmodule AshOnetimeDemo.RedeemRun do
use Ash.Resource.Actions.Implementation
@impl true
def run(input, _opts, _context), do: AshOnetime.ExternalEffect.result(input)
endProtect the action
The action returns the accepted ledger result through ExternalEffect.result/1.
It does not reconstruct a result from request input.
defmodule AshOnetimeDemo.Domain do
use Ash.Domain, validate_config_inclusion?: false
resources do
resource AshOnetimeDemo.ExternalRedeem
end
end
defmodule AshOnetimeDemo.ExternalRedeem do
use Ash.Resource,
domain: AshOnetimeDemo.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshOnetime.Resource]
postgres do
table "demo_external_redeem"
repo AshOnetimeDemo.Repo
end
multitenancy do
strategy :context
end
attributes do
uuid_primary_key :id
end
actions do
defaults [:read]
action :redeem, :integer do
transaction? true
argument :value, :integer, allow_nil?: false
argument :request_key, :string, allow_nil?: false
run {AshOnetimeDemo.RedeemRun, []}
end
end
onetime do
protect :redeem do
strategy :idempotency
scope([{:static, "external"}])
key({:client, :request_key})
fingerprint(arguments: [:value])
response(AshOnetimeDemo.ChargeCodec, fields: [], classify: AshOnetimeDemo.ChargeClassifier)
retention({1, :hour})
external_effect(AshOnetimeDemo.PeerAdapter)
end
end
end
:okCreate this run's store and ledger
alias AshOnetimeDemo.{Repo, PeerRepo}
alias Ecto.Adapters.SQL
unique = Ecto.UUID.generate() |> String.replace("-", "")
schema = "ash_onetime_demo_external_#{unique}"
temp = Path.join(System.tmp_dir!(), schema)
File.mkdir_p!(temp)
schemasource = Mix.Tasks.AshOnetime.Gen.Migrations.render(Repo,
hash_partitions: nil,
partition_start: Date.utc_today() |> Date.beginning_of_month())
timestamp = "20260101000000"
path = Path.join(temp, "#{timestamp}_install_ash_onetime.exs")
File.write!(path, source)
[{module, _}] = Code.compile_file(path)
version = String.to_integer(timestamp)
SQL.query!(Repo, ~s{CREATE SCHEMA "#{schema}"}, [])
:ok = Ecto.Migrator.up(Repo, version, module, prefix: schema, log: false)
SQL.query!(Repo, ~s{CREATE TABLE "#{schema}".demo_external_redeem (id uuid PRIMARY KEY)}, [])
SQL.query!(PeerRepo, """
CREATE TABLE "#{schema}".external_receipts (operation_key uuid PRIMARY KEY, value bigint NOT NULL CHECK (value >= 0))
""", [])
SQL.query!(PeerRepo, """
CREATE TABLE "#{schema}".external_invocations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operation_key uuid NOT NULL,
kind text NOT NULL CHECK (kind IN ('execute', 'recover'))
)
""", [])
run = fn key, value ->
AshOnetimeDemo.ExternalRedeem
|> Ash.ActionInput.for_action(:redeem, %{value: value, request_key: key})
|> Ash.ActionInput.set_tenant(schema)
|> Ash.run_action()
end
call_count = fn kind ->
[[count]] = SQL.query!(PeerRepo,
~s{SELECT count(*) FROM "#{schema}".external_invocations WHERE kind = $1}, [kind]).rows
count
end
:okExecute once, then replay the stored result
The first call commits one receipt. A successful retry replays the locally stored result: it needs neither a second execute nor recovery lookup. Primitive-return actions do not carry a record replay marker, so we observe the actual callback log instead.
{:ok, 42} = run.("ext-1", 42)
{:ok, 42} = run.("ext-1", 42)
1 = call_count.("execute")
0 = call_count.("recover")
%{value: 42, execute_calls: 1, recover_calls: 0}%{execute_calls: 1, recover_calls: 0, value: 42}Roll back the caller after the ledger commits
The caller rolls back its actual transaction after the protected action returns.
The independently committed receipt survives; the admission claim survives as
processing, without a completed local replay response.
{:error, :caller_abort} = Repo.transaction(fn ->
{:ok, 99} = run.("ext-recovery-1", 99)
Repo.rollback(:caller_abort)
end)
[[operation_key]] = SQL.query!(PeerRepo,
~s{SELECT operation_key::text FROM "#{schema}".external_receipts WHERE value = 99}, []).rows
[["processing"]] = SQL.query!(Repo,
~s{SELECT state FROM "#{schema}".ash_onetime_idempotency_claims WHERE id = $1::text::uuid},
[operation_key]).rows
%{caller_rolled_back: true, durable_peer_value: 99, claim_state: "processing"}%{caller_rolled_back: true, claim_state: "processing", durable_peer_value: 99}Recover the result under the same operation key
Retrying this incomplete claim invokes recover/3. The ledger returns the existing receipt
and the action completes, without another execution. The append-only callback log proves
both decisions for this exact operation key.
{:ok, 99} = run.("ext-recovery-1", 99)
[[1, 1]] = SQL.query!(PeerRepo, """
SELECT count(*) FILTER (WHERE kind = 'execute'), count(*) FILTER (WHERE kind = 'recover')
FROM "#{schema}".external_invocations WHERE operation_key = $1::text::uuid
""", [operation_key]).rows
[[1]] = SQL.query!(PeerRepo,
~s{SELECT count(*) FROM "#{schema}".external_receipts WHERE operation_key = $1::text::uuid},
[operation_key]).rows
%{value: 99, execute_calls: 1, recover_calls: 1, receipts: 1}%{execute_calls: 1, receipts: 1, recover_calls: 1, value: 99}Observe a real blocked peer, then retry concurrently
A separate PostgreSQL session takes a table lock on the ledger. The original's receipt
insert blocks on that real lock. We confirm the wait in pg_locks before issuing a retry:
the retry cannot acquire the pre-peer claim lock and returns :request_in_progress
within external_lock_timeout_ms (default 2000).
The lock holder releases on completion or after a bounded wait. It affects only this notebook's unique schema.
parent = self()
locker = Task.async(fn ->
PeerRepo.transaction(fn ->
SQL.query!(PeerRepo, ~s{LOCK TABLE "#{schema}".external_receipts IN ACCESS EXCLUSIVE MODE}, [])
send(parent, :ledger_locked)
receive do
:release -> :ok
after
15_000 -> PeerRepo.rollback(:lock_holder_deadline)
end
end, timeout: 20_000)
end)
receive do
:ledger_locked -> :ok
after
5_000 -> raise "ledger lock was not acquired"
end
original = Task.async(fn -> run.("ext-lock-1", 7) end)
retry_code = try do
blocked = Enum.reduce_while(1..200, false, fn _, _ ->
[[count]] = SQL.query!(Repo, """
SELECT count(*) FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.relation = $1::text::regclass AND NOT l.granted
AND a.wait_event_type = 'Lock' AND a.query LIKE 'INSERT INTO%external_receipts%'
""", [~s{"#{schema}".external_receipts}]).rows
if count == 1 do
{:halt, true}
else
Process.sleep(20)
{:cont, false}
end
end)
true = blocked
{:error, refusal} = run.("ext-lock-1", 7)
:request_in_progress = AshOnetime.Error.code(refusal)
after
send(locker.pid, :release)
end
{:ok, :ok} = Task.await(locker, 10_000)
{:ok, 7} = Task.await(original, 10_000)
3 = call_count.("execute")
1 = call_count.("recover")
%{concurrent_retry: retry_code, total_execute_calls: 3}%{concurrent_retry: :request_in_progress, total_execute_calls: 3}The concurrent retry never called the peer. Once the original completes, a later retry
can replay its stored result. For an unreachable peer, :unknown means the library cannot
authorize another execution. Adapters must bound calls and preserve that distinction.
See operations for abandoned-claim
reconciliation and the reaper's boundary.
Observe a failed receipt write
The ledger rejects negative values with a real PostgreSQL constraint. After the failed
execute, an authoritative recovery lookup confirms that no receipt exists; the action
returns :external_effect_unavailable. Its separately committed invocation remains visible:
before_failed_execute = call_count.("execute")
{:error, failed_receipt} = run.("negative-receipt", -1)
:external_effect_unavailable = AshOnetime.Error.code(failed_receipt)
true = call_count.("execute") == before_failed_execute + 1
[[0]] = SQL.query!(PeerRepo,
~s{SELECT count(*) FROM "#{schema}".external_receipts WHERE value = -1}, []).rows
%{failed_execute_logged: true, receipt_count: 0}%{failed_execute_logged: true, receipt_count: 0}Clean up
:ok = Ecto.Migrator.down(Repo, version, module, prefix: schema, log: false)
SQL.query!(Repo, ~s{DROP SCHEMA "#{schema}" CASCADE}, [])
File.rm_rf!(temp)
:okContinue
- Idempotency — local effects and exact replay.
- One-time nonces — proof verification and collision rejection.
- External effects — adapter and peer requirements.
- Transaction-owned admission — compose admission in your transaction.