RequestSeal.Replay.AshOnetime (RequestSeal v0.5.2)

Copy Markdown View Source

Recommended durable replay store over AshOnetime.Transaction.nonce/2.

Requires optional ash_onetime 1.5 and Elixir 1.20. The caller owns the existing Ecto repo, PostgreSQL database, and ash_onetime installation migrations. Namespace and commitment bytes are base64url encoded into scope and key; they never become SQL identifiers or logical partitions.

Each claim owns a READ COMMITTED transaction under the remaining deadline. This transaction commits independently of the verifier's application effect. To spend a nonce together with an effect, verify with replay explicitly :not_required, then use ash_onetime in the application's transaction. No timeout or failure permits automatic retry: the claim may have committed.

Facts use PostgreSQL's transaction clock, read in the same transaction as admission. Max age is derived from retain_until; one second of rounding headroom and ash_onetime's cleanup margin keep retention beyond that horizon without relying on application/database clock synchronization. ash_onetime's freshness check can still reject clock skew; rejection never spends a nonce. Past or current retention horizons still block an immediate duplicate. Historical verification clocks do not move the database's cleanup clock. Duplicate claims do not extend retention; use one freshness bound per namespace.

sweep/3 never deletes anything. Cleanup belongs to mix ash_onetime.prune or AshOnetime.Oban.CleanupWorker; this adapter never calls ash_onetime's internal store APIs or starts any process.

Summary

Functions

Atomically claim an envelope in an independently committed transaction.

Reference an existing repo with an explicit logical partition and optional schema prefix.

Refuse to sweep: cleanup is externally managed by ash_onetime's prune task or Oban worker.

Types

ref()

@type ref() :: {module(), binary(), binary() | nil}

Functions

claim(ref, claim, context)

@spec claim(ref(), RequestSeal.Replay.Claim.t(), RequestSeal.Custody.Context.t()) ::
  :claimed | :already_claimed | {:error, :unavailable | :timeout | :failure}

Atomically claim an envelope in an independently committed transaction.

Implements RequestSeal.Replay.claim/3; most callers use RequestSeal.Replay.claim/3 with the store from store/2. The transaction and its queries use the remaining time of context as their timeout.

Parameters

Returns

  • :claimed - ash_onetime admitted the nonce.
  • :already_claimed - ash_onetime reported :nonce_already_used.
  • {:error, :timeout} - the deadline had expired before the transaction; a lock or cancellation; and, from 0.5.0, a pool queue timeout (DBConnection :queue_timeout) or any failure observed after the deadline, such as a transaction rolled back when the pool closed the connection at the client timeout. The claim may have committed.
  • {:error, :unavailable} - the repo is not running, a connection could not be checked out, or the database is shutting down or refusing connections.
  • {:error, :failure} - an invalid claim or reference, any other ash_onetime error code, or any other failure.

Examples

iex> context = %RequestSeal.Custody.Context{
...>   owner: self(),
...>   deadline: System.monotonic_time(:millisecond) + 1_000
...> }
iex> claim = %RequestSeal.Replay.Claim{namespace: "orders", key: "", retain_until: 100}
iex> RequestSeal.Replay.AshOnetime.claim({Example.Repo, "webhooks", nil}, claim, context)
{:error, :failure}

store(repo, opts)

Reference an existing repo with an explicit logical partition and optional schema prefix.

Building the reference does not contact the database; the repo module must be loaded and export transaction/2.

Parameters

  • repo - the caller's Ecto repo module.
  • opts - keyword list without duplicate keys; see Options.

Options

  • :partition - required: the ash_onetime logical partition, a valid UTF-8 string of 1–255 bytes without NUL bytes.
  • :prefix - optional schema prefix, a valid UTF-8 string of 1–63 bytes without NUL bytes. Default nil (the repo's default schema).

Returns

Examples

iex> RequestSeal.Replay.AshOnetime.store(Example.Repo, partition: "")
{:error, %RequestSeal.Replay.Error{reason: :invalid_store, retryable: false}}

Fragment (requires a running Ecto repo with the ash_onetime migrations; not executed here):

store = RequestSeal.Replay.AshOnetime.store(Example.Repo, partition: "webhooks")
claim = %RequestSeal.Replay.Claim{namespace: "orders", key: "nonce-7f3a", retain_until: 1_900_000_000}
:claimed = RequestSeal.Replay.claim(store, claim, timeout: 2_000)
:already_claimed = RequestSeal.Replay.claim(store, claim, timeout: 2_000)

sweep(ref, now, context)

@spec sweep(ref(), integer(), RequestSeal.Custody.Context.t()) ::
  {:error, :externally_managed}

Refuse to sweep: cleanup is externally managed by ash_onetime's prune task or Oban worker.

Implements RequestSeal.Replay.sweep/3 without touching the database.

Parameters

  • ref - ignored.
  • now - ignored.
  • context - ignored.

Returns

  • {:error, :externally_managed} - always.

Examples

iex> context = %RequestSeal.Custody.Context{
...>   owner: self(),
...>   deadline: System.monotonic_time(:millisecond) + 1_000
...> }
iex> RequestSeal.Replay.AshOnetime.sweep({Example.Repo, "webhooks", nil}, 1_900_000_000, context)
{:error, :externally_managed}