AuroraMeter. Entitlements
(Aurora Meter v1.0.0)
View Source
Plan resolution and the entitlement gate.
Semantics (see plan.md D12 and ADR 0010): a :hard limit blocks at its cap; a
:metered feature is always allowed (overage is billed); a :counter is
always allowed and never billed (ADR 0006); a {:feature, false} is denied.
with_quota/4 reserves atomically so hard limits are correct under
concurrency, releasing the reservation if the wrapped function raises.
Features the plan does not declare
A feature the tenant's effective plan does not declare follows
:undeclared_feature_policy (AuroraMeter.Config.policy_for/1). A feature
another plan declares counts as undeclared here: that is the case the policy
exists for.
| Entry point | :allow | :warn | :deny | :raise |
|---|---|---|---|---|
check/2 | :ok | :ok plus one log | {:error, :not_entitled} | raises |
allowed?/2, entitled?/2 | true | true plus one log | false | raises |
feature_value/3 | default | default plus one log | default | raises |
quota/2 | kind: :undeclared, enabled: true | same plus one log | kind: :undeclared, enabled: false | raises |
remaining/2 | :unlimited | :unlimited plus one log | 0 | raises |
reserve/2,3 | counts, :ok | counts, :ok, one log | {:error, :not_entitled}, counter untouched | raises, counter untouched |
with_quota/3,4 | runs the function | runs it, one log | {:error, :not_entitled}, not run | raises, not run |
Every entry point keeps its documented return shape under every policy; only
the value inside it changes. :warn behaves exactly as :allow and logs once
per feature per node. AuroraMeter.track/4 is deliberately outside all of
this and keeps counting: metering is not entitlement.
The default is :warn in the 0.5.x transition release and :deny from 1.0;
config :aurora_meter, undeclared_feature_policy: :allow restores the 0.4.x
behaviour exactly.
Plans resolve through AuroraMeter.Subscriptions (cached), and only a
subscription in an entitled status (AuroraMeter.Schema.Subscription.entitled_statuses/0)
grants its plan; anything else gets the default plan.
Summary
Types
Result of an entitlement check.
A dashboard-ready view of one feature's quota. kind is :hard, :metered,
:counter, :boolean, :feature (an integer plan value, carried in value)
or :undeclared; limit is set for hard caps, included for metered
allowances; percent is used relative to whichever applies (nil when neither
does, which includes every :counter; see ADR 0006).
Functions
Whether check/2 currently returns :ok.
Checks whether tenant may use feature right now.
Whether the tenant's plan grants access to feature at all (ignores quota).
The value of a feature :name, value declaration on tenant's plan, or
default when the plan does not declare it (or declares it as a limit or a
metered feature). Booleans and non-negative integers are both values
Returns the plan for tenant: the version its subscription is pinned to
when the subscription is in an entitled status, else the default plan.
A dashboard-ready snapshot of feature for tenant: kind, usage, cap or
allowance, remaining, overage, percentage and the current period.
Remaining quota for a hard-limited feature, or :unlimited, which is what a
metered feature, a counter and a plain feature all report, because none of
them has a cap to count down from.
Atomically reserves qty of feature against the plan (increments the
counter).
Assigns plan_id to tenant locally (no billing provider).
Assigns a specific version of plan_id to tenant.
Gates, runs, and meters in one atomic step.
Types
@type check_result() :: :ok | {:error, :limit_exceeded | :not_entitled}
Result of an entitlement check.
@type quota() :: %{ feature: atom(), kind: :hard | :metered | :counter | :boolean | :feature | :undeclared, enabled: boolean(), value: non_neg_integer() | nil, used: integer(), limit: non_neg_integer() | nil, included: non_neg_integer() | nil, unit_price: number() | nil, remaining: non_neg_integer() | :unlimited, overage: non_neg_integer(), percent: non_neg_integer() | nil, period: AuroraMeter.Period.t() }
A dashboard-ready view of one feature's quota. kind is :hard, :metered,
:counter, :boolean, :feature (an integer plan value, carried in value)
or :undeclared; limit is set for hard caps, included for metered
allowances; percent is used relative to whichever applies (nil when neither
does, which includes every :counter; see ADR 0006).
Functions
Whether check/2 currently returns :ok.
@spec check(term(), atom()) :: check_result()
Checks whether tenant may use feature right now.
Whether the tenant's plan grants access to feature at all (ignores quota).
@spec feature_value(term(), atom(), default) :: boolean() | non_neg_integer() | default when default: term()
The value of a feature :name, value declaration on tenant's plan, or
default when the plan does not declare it (or declares it as a limit or a
metered feature). Booleans and non-negative integers are both values:
AuroraMeter.feature_value(org, :seats, 1) # 5 on :pro, 1 on :free
AuroraMeter.feature_value(org, :api_access) # true | false | nildefault is also what an undeclared feature yields under :allow, :warn
and :deny; under :raise it raises AuroraMeter.UndeclaredFeatureError.
@spec plan(term()) :: AuroraMeter.Plan.t() | nil
Returns the plan for tenant: the version its subscription is pinned to
when the subscription is in an entitled status, else the default plan.
The pin is what makes a plan deploy safe. A tenant on :pro version "1"
keeps version 1's limits, feature values, price and recurring credits when
version 2 becomes effective, and keeps them when version 1's block is deleted
from the plans module, because the definition is resolved from the registered
snapshot (v1-release.md 07.03, invariant I17).
A row with no plan_version at all (written before core schema version 10 and
not yet named by AuroraMeter.Plans.register!/0) resolves to the plan's
base version, never to whatever is current: the migration and the
registration are not simultaneous, and a tenant must not be repriced in the
window between them.
A dashboard-ready snapshot of feature for tenant: kind, usage, cap or
allowance, remaining, overage, percentage and the current period.
@spec remaining(term(), atom()) :: non_neg_integer() | :unlimited
Remaining quota for a hard-limited feature, or :unlimited, which is what a
metered feature, a counter and a plain feature all report, because none of
them has a cap to count down from.
An undeclared feature reports :unlimited under :allow and :warn, and
0 under :deny: the documented return type is non_neg_integer() | :unlimited, and 0 is the honest number when nothing is entitled.
@spec reserve(term(), atom(), pos_integer(), DateTime.t() | nil) :: :ok | {:error, :limit_exceeded | :not_entitled}
Atomically reserves qty of feature against the plan (increments the
counter).
period_start names the period to count it against; without it the current
one is used. A caller that will release the reservation later has to hold on
to the period it reserved in; see with_quota/4.
Raises ArgumentError for a feature configured
feature_sources: %{name => :events}. This is the bill-immediately primitive:
it writes the quantity straight into the pending flush, so it would put
reserved units into aurora_meter_counters for a feature whose commercial
quantity is its recorded events. Use with_quota/4, whose reservation stays
in memory, or check/2 to ask without reserving.
@spec subscribe(term(), atom() | String.t()) :: {:ok, AuroraMeter.Schema.Subscription.t()} | {:error, Ecto.Changeset.t()}
Assigns plan_id to tenant locally (no billing provider).
The plan has to exist. A plan id no plans module declares used to be written
and then silently resolved to the default plan for the life of the install; in
the transition release it is written with a warning, and from 1.0 it is
{:error, changeset} with plan_id: ["is not a known plan"].
@spec subscribe(term(), atom() | String.t(), keyword() | :strict | :transition) :: {:ok, AuroraMeter.Schema.Subscription.t()} | {:error, Ecto.Changeset.t()}
Assigns a specific version of plan_id to tenant.
Options:
:version: the plan version to pin. Without it the tenant gets the version effective atAuroraMeter.Clock.now/0, which is what "new subscriptions select the intended effective version" means. With it, the named version is pinned even when it is not yet effective: an explicit opt-in is not the same thing as a future-dated version becoming active early, because the caller asked for it.:mode: undocumented, for the suite, so both halves of the transition can be exercised without depending on the package's own version.
A version the plan does not have, in code or in the registry, is
{:error, changeset} with plan_version: ["is not a known version of this plan"].
The row records the version, its fingerprint and the instant the assignment started, so a later deploy of a new version cannot change what this tenant was sold (D05).
@spec with_quota(term(), atom(), (-> result)) :: {:ok, result} | {:error, term()} when result: term()
Gates, runs, and meters in one atomic step.
Reserves qty of feature; if allowed, runs fun and returns {:ok, result}
(the reservation is the usage). If the reservation is denied, returns
{:error, reason} without running fun. If fun raises, the reservation is
released and the error re-raised.
Over an events-source feature
For a feature configured feature_sources: %{name => :events} the gate still
works and the reservation is still strict on this node, but the reservation is
released on success instead of being committed. It is admission control
and nothing else: the billable fact is whatever AuroraMeter.record/4
committed, and committing the reservation as well would charge the estimate on
top of the recorded quantity.
The recipe, and the arithmetic it produces, is to record inside the callback:
AuroraMeter.with_quota(org, :tokens, estimate, fn ->
{:ok, result} = do_work()
{:ok, _event, _outcome} =
AuroraMeter.record(org, :tokens, result.tokens,
id: result.request_id, occurred_at: result.finished_at)
result
end)+estimate at admission, +result.tokens from the projection, -estimate at
release: the in-memory value nets to the durable total, and while the callback
runs every other caller sees the estimate held. Unlike a buffered feature,
nothing here can reach a flush batch.
@spec with_quota(term(), atom(), pos_integer(), (-> result)) :: {:ok, result} | {:error, term()} when result: term()