AuroraMeter. Credits. Recurrences
(Aurora Meter v1.0.0)
View Source
Recurring credit allowances: one grant per tenant, entitlement, plan version and billing period, with a capped rollover and a bounded catch-up after downtime.
A plan declares the policy with AuroraMeter.Plans.recurring_credits/2; a
plan that declares none grants nothing, which is what "recurring grants
default to disabled" means. run/1 is the operation: call it from Oban
(AuroraMeter.Oban.RecurringGrants), from Quantum, from a Kubernetes CronJob
or from iex. It is not a process and holds no state between runs.
AuroraMeter.Credits.Recurrences.run(limit: 5_000)What a run does
It walks entitled subscriptions in keyset order, resolves each tenant's plan, skips it immediately when the plan declares no allowance, and works out which periods each allowance still owes. Each period is its own transaction under the wallet's balance row lock, so a twelve-period catch-up is twelve short transactions and a concurrent debit interleaves between them.
Size the run so it can visit every entitled tenant at least once per period:
limit: 5_000 on an hourly schedule visits 120,000 tenants a day.
Once per period, whatever the scheduler does
The guarantee is not that the job runs once. Two guards, both evaluated inside the balance row lock, make a second run a no-op:
aurora_meter_credit_recurrencesisUNIQUE (tenant_key, key), and the period's row is inserted withON CONFLICT DO NOTHING. Zero rows back means another node has this period.- the grant carries the period's reference, and
aurora_meter_credit_transactionsisUNIQUE (kind, reference).
The two are deliberately redundant because this is money. Neither is a lease
and neither is a duration, so neither can be inverted by a clock that steps
backwards (AuroraMeter.Clock).
Two clocks
Choosing a period is a wall-clock question ("what period is it now"), so the
walk takes AuroraMeter.Clock.now/0. Every decision inside a transaction
compares against a column this database stamped, so those take
AuroraMeter.Clock.db_now/0. Ordering never takes a clock at all: periods are
ordered by period_start, which is a boundary the period source computed, not
a reading of any clock.
Rollover
rollover: n carries at most n micro-dollars of one period's unused
allowance into the next, as a new lot with its own reference and its own
allocation trail. Unused means what that period's lots did not spend, whether
or not the expiry sweep has already destroyed it: available + expired is the
same number either way, which is what makes the carry independent of whether
the sweep or this engine reached the lot first.
It does not accumulate. Two idle periods with a cap of 1,000,000 leave the third holding its own allowance plus 1,000,000, never 2,000,000, because the cap applies to the whole of the previous period rather than to each of its lots.
The cap comes from the previous period's stored policy, never from the compiled plan. Raising a plan's cap does not retroactively raise what an already-issued period may carry out of itself.
Catch-up, and why a missed period is not fresh money
A tenant whose job has not run for three periods gets those three periods in
chronological order, each granted and expired in the same transaction and
recorded issued_and_expired, then its live period. The history is complete
and nothing that was owed months ago arrives spendable today.
A tenant seen for the first time gets its current period only. Aurora Meter does not invent history it never recorded, so there is no back-pay for periods before a tenant's first recurrence row, and adopting an allowance mid-period grants the whole of that period rather than a pro-rated part of it.
Requirements and limits
The wallet must be on the lot engine (
lots_enabled_atset). A wallet the allocator does not own is skipped withreason: :lots_disabled: the allowance, the cap and the catch-up are all defined over lots, and a second, weaker implementation over the legacy projection is not one this package will carry. Seedocs/upgrading-to-lots.md.A tenant with no wallet at all is not skipped: since 0.5.0 the grant that pays the allowance is what creates the wallet, and a wallet created now is born on the allocator. This is the ordinary case for a customer who has just subscribed, and an allowance is usually the first credit a wallet ever sees.
The storage adapter must implement
AuroraMeter.Storage.list_subscriptions/2. A run that cannot list subscriptions returns{:error, {:unsupported, :list_subscriptions}}; drive it with explicit:tenantvalues instead.
Pausing
AuroraMeter.Operations.pause("credits_recurrences:global")A paused run returns {:ok, %{paused: true}} and writes nothing. The pause is
read before every batch, so it takes effect within one batch rather than one
run.
Summary
Types
What the operator sees between runs.
What one run did. The map may gain keys in a later release.
Functions
The reserved reference namespace, "recurring:".
The operation name this engine pauses, resumes and checkpoints under.
The periods one entitlement still owes, oldest first.
Issues every recurring allowance that has come due.
What the engine has done, and whether an operator has stopped it.
Types
@type status() :: %{ name: String.t(), paused: boolean(), cursor: map() | nil, counts: map(), state: String.t() | nil, recurrences: [AuroraMeter.Schema.CreditRecurrence.t()] }
What the operator sees between runs.
@type summary() :: %{ paused: boolean(), counts: %{optional(String.t()) => non_neg_integer()}, reasons: %{optional(String.t()) => non_neg_integer()}, cursor: map() | nil, stopped: :complete | :paused | :max_batches, dry_run: boolean() }
What one run did. The map may gain keys in a later release.
Functions
@spec namespace() :: String.t()
The reserved reference namespace, "recurring:".
AuroraMeter.Credits.grant/3, hold/4, debit/4 and reverse/4 raise
ArgumentError for a caller-supplied reference beginning with it, because
this engine mints its own references there and a collision would make a host's
manual grant look like a period that had already been issued. Nothing else is
reserved: manual grants keep using any string.
Examples
iex> AuroraMeter.Credits.Recurrences.namespace()
"recurring:"
@spec operation() :: String.t()
The operation name this engine pauses, resumes and checkpoints under.
Examples
iex> AuroraMeter.Credits.Recurrences.operation()
"credits_recurrences:global"
@spec periods(term(), DateTime.t() | nil, DateTime.t(), pos_integer()) :: {:ok, [AuroraMeter.Period.t()], :complete | :truncated | :up_to_date} | {:error, term()}
The periods one entitlement still owes, oldest first.
last_start is the newest period already recorded, or nil for a tenant with
no recurrence row, which gets its current period and no back-pay. The walk is
expressed entirely in terms the period source can answer
(AuroraMeter.Period.containing/2 of the previous period's exclusive end), so
a weekly, a subscription-aligned or any other custom source walks correctly
with no arithmetic on month boundaries anywhere.
Returns {:ok, periods, :complete | :truncated | :up_to_date}, or
{:error, reason} for a source that raises or that does not move forward.
:truncated means max_periods was reached with the live period still ahead;
the next run continues from where this one stopped. :up_to_date means the
live period is already recorded, which is what the second and every later run
inside one period sees: the engine recognises the period from its own row and
opens no transaction at all.
Examples
iex> {:ok, periods, :complete} =
...> AuroraMeter.Credits.Recurrences.periods("org_1", nil, ~U[2026-09-16 10:00:00Z], 12)
iex> Enum.map(periods, & &1.start)
[~U[2026-09-01 00:00:00Z]]
iex> {:ok, periods, :complete} =
...> AuroraMeter.Credits.Recurrences.periods(
...> "org_1", ~U[2026-07-01 00:00:00Z], ~U[2026-09-16 10:00:00Z], 12)
iex> Enum.map(periods, & &1.start)
[~U[2026-08-01 00:00:00Z], ~U[2026-09-01 00:00:00Z]]
Issues every recurring allowance that has come due.
:limit(pos_integer/0) - The most tenants one run visits. The default value is500.:batch(pos_integer/0) - Tenants per batch, and therefore per checkpoint write. The default value is50.:max_periods(pos_integer/0) - The most catch-up periods one run processes for one entitlement. The default value is12.:tenant(term/0) - One tenant or a list of them, processed without the subscription scan. The default value isnil.:now(term/0) - The instant periods are resolved against. Defaults toClock.now/0. The default value isnil.:dry_run(boolean/0) - Report what would be granted and write nothing. The default value isfalse.
Returns {:ok, summary}. A tenant that cannot be processed is counted and
stepped over: one wallet's fault must not starve the wallets behind it, and
the direction it fails in is the safe one, a grant not yet made. Only a
failure of the scan itself ends the run with {:error, reason}.
Emits [:aurora_meter, :credits, :recurrence] per period examined, with
measurements %{amount, rollover_amount} and metadata %{tenant_key, name, plan_id, plan_version, period_start, result, reason}.
Examples
{:ok, summary} = AuroraMeter.Credits.Recurrences.run(tenant: "org_1")
summary.counts["granted"]
#=> 1
What the engine has done, and whether an operator has stopped it.
Options: :tenant (also return that tenant's recurrence rows, newest period
first) and :limit (default 50) on those rows.
Examples
AuroraMeter.Credits.Recurrences.status(tenant: "org_1")