AuroraMeter.Credits.HoldReconciler behaviour (Aurora Meter v1.0.0)

View Source

What a host says about a hold that is still open long after its work should have finished.

A hold is taken before the row that remembers it exists, so a process killed in between leaves money reserved against a tenant with nothing left pointing at it. The ledger can list those holds (AuroraMeter.Credits.pending_holds/1) and it can close them, but it cannot tell one apart from a hold whose work is simply still running. Only the host knows that, and this behaviour is how it says so.

defmodule MyApp.HoldPolicy do
  @behaviour AuroraMeter.Credits.HoldReconciler

  @impl true
  def decide(%{reference: "job:" <> id}) do
    case MyApp.Jobs.get(id) do
      %{state: :running} -> :keep
      %{state: :failed} -> :release
      %{state: :done, cost: cost} -> {:settle, cost}
      nil -> :release
    end
  end

  def decide(_hold), do: :keep
end

config :aurora_meter, credits_hold_reconciler: MyApp.HoldPolicy

Configure it as the module above, as a {module, function} pair of arity 1, or as a one-argument function. nil, the default, keeps every hold.

Age is not evidence

The :age_seconds and :held_at fields say how long the hold has been open. They do not say the work is abandoned, and nothing in Aurora Meter will ever treat them that way. A job that legitimately runs for nine hours and a job whose process was killed nine hours ago are the same row. Deciding from the age alone releases money that is about to be spent, and the settle that follows takes the balance negative.

There is a second reason not to lean on the age, and it is mechanical. A hold row's inserted_at is stamped by the node that took the hold, from a wall clock that is not monotonic and is not shared with the node running the reconciler. The age is therefore accurate to within whatever those two clocks disagree by. It is a hint for a log line and a metric, not a predicate.

Decide from something that actually knows: the host's own job table, its queue, a heartbeat the worker writes.

Rules for an implementation

  • decide/1 may be called more than once for one hold. Two nodes running a sweep at the same time both list it and both ask. Make the callback side-effect free, or idempotent.
  • It runs outside any database transaction and while no ledger row lock is held, in a process of its own under AuroraMeter.TaskSupervisor.
  • It must return within :credits_hold_reconciler_timeout (default 5000 ms). Past that the process is killed with :brutal_kill and the hold is kept. Do not make a network call from it.
  • Anything that is not a valid decision means :keep. A raise, an exit, a throw, a timeout, a value that is not :keep, :release or {:settle, non_neg_integer}, or no configured reconciler at all: every one of them keeps the hold. There is no path on which a failure releases money.
  • A decision is advisory until it is applied. Between decide/1 returning and the ledger applying it, the hold's own worker may have closed it; the application re-reads the status under the row lock and the run reports :already_closed.

Dry runs

AuroraMeter.Credits.reconcile_holds(older_than: t, reconciler: fn _ -> :keep end) examines and reports without moving anything, which is how to find out what a sweep would look at before configuring a policy that can release money.

Summary

Types

Keep the reservation, hand it back, or charge amount micro-USD and close it.

One open hold, as it stood when the run listed it.

Callbacks

What to do with hold.

Types

decision()

@type decision() :: :keep | :release | {:settle, non_neg_integer()}

Keep the reservation, hand it back, or charge amount micro-USD and close it.

{:settle, 0} is a settle for nothing, which closes the hold and returns the whole reservation. It is not the same as :release: the ledger records a :settle entry rather than a :release one, so the log says the work finished and cost nothing rather than that it never finished.

hold()

@type hold() :: %{
  tenant_key: String.t(),
  reference: String.t(),
  amount: pos_integer(),
  held_at: DateTime.t(),
  age_seconds: non_neg_integer(),
  metadata: map()
}

One open hold, as it stood when the run listed it.

amount is the reserved micro-USD, held_at the hold row's inserted_at, age_seconds the difference between the instant the run started and held_at, never negative, and metadata whatever the host passed to AuroraMeter.Credits.hold/4.

amount here is the hold row's held_delta, not its amount column. The two are different numbers and the column is the wrong one: a hold moves the reserved figure and not the balance, so amount on the row is 0 for every hold there is, while this map's amount is the reservation. It is spelled amount because micro-USD money is spelled amount throughout this package (api-change-map.md section 5), and the trap is that AuroraMeter.Credits.pending_holds/1, the other half of this job, hands back the raw row rather than this map (open-findings.md X396). If you are reading a figure off a row, read held_delta.

Callbacks

decide(hold)

@callback decide(hold()) :: decision()

What to do with hold.

Called once per hold per run, outside any transaction, in its own process. Anything other than a decision/0 keeps the hold.