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_recurrences is UNIQUE (tenant_key, key), and the period's row is inserted with ON CONFLICT DO NOTHING. Zero rows back means another node has this period.
  • the grant carries the period's reference, and aurora_meter_credit_transactions is UNIQUE (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_at set). A wallet the allocator does not own is skipped with reason: :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. See docs/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 :tenant values 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

status()

@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.

summary()

@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

namespace()

@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:"

operation()

@spec operation() :: String.t()

The operation name this engine pauses, resumes and checkpoints under.

Examples

iex> AuroraMeter.Credits.Recurrences.operation()
"credits_recurrences:global"

periods(tenant, last_start, now, max_periods)

@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]]

run(opts \\ [])

@spec run(keyword()) :: {:ok, summary()} | {:error, term()}

Issues every recurring allowance that has come due.

  • :limit (pos_integer/0) - The most tenants one run visits. The default value is 500.

  • :batch (pos_integer/0) - Tenants per batch, and therefore per checkpoint write. The default value is 50.

  • :max_periods (pos_integer/0) - The most catch-up periods one run processes for one entitlement. The default value is 12.

  • :tenant (term/0) - One tenant or a list of them, processed without the subscription scan. The default value is nil.

  • :now (term/0) - The instant periods are resolved against. Defaults to Clock.now/0. The default value is nil.

  • :dry_run (boolean/0) - Report what would be granted and write nothing. The default value is false.

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

status(opts \\ [])

@spec status(keyword()) :: status()

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")