AuroraMeter. Retention
(Aurora Meter v1.0.0)
View Source
Deletes the operational rows that are genuinely disposable, and nothing else.
Aurora Meter keeps financial history for ever. Events, event totals, the credit ledger, subscriptions and the cursors every sweep resumes from are never deleted by anything in this package, at any age, under any option. What grows without bound and is genuinely disposable is a much shorter list, and this module is that list plus the proof that each entry is safe to remove.
AuroraMeter.Retention.plan([])
#=> {:ok, %{flush_receipts: 4_182_000, replay_checkpoints: 3}}
AuroraMeter.Retention.prune([])
#=> {:ok, %{flush_receipts: 50_000, replay_checkpoints: 3}}plan/1 is a dry run: it runs the prune's own predicate with count(*)
instead of a DELETE and writes nothing at all.
The allow list is closed, and it is compile time
| Table | Age | State | Window |
|---|---|---|---|
aurora_meter_flush_receipts | inserted_at | every node's flush heartbeat proves no node holds an older batch | flush_receipt_retention, 30 days |
aurora_meter_checkpoints (only "events_replay:<generation>" rows) | updated_at | activated or abandoned, and not a generation the live projection names | replay_checkpoint_retention, 365 days |
There is no option, no configuration key and no argument that adds a table to
it. :only narrows the list; it can never widen it, which is why it is named
:only rather than :tables. A name that is not an allow-list key raises
ArgumentError and the message lists the two that are.
Age alone never authorises a deletion. Every entry has an age predicate and a state predicate, because "this row is old" and "this row can no longer affect anything" are different statements and only the second is a reason to delete.
Why a flush receipt is the dangerous one
AuroraMeter.Storage.Ecto.flush_batch/3 inserts the batch's receipt with
on_conflict: :nothing and applies the counter deltas only when that insert
reported one new row. That single inserted == 1 is the whole of invariant
I01. Delete a receipt while a node still holds the batch in ETS, let that node
retry, and the deltas are added a second time: a double count of real usage,
in money.
So a receipt is deleted only when age and a cluster-wide liveness proof
agree. Every node's AuroraMeter.Flusher writes a "flush:<node>" row into
aurora_meter_checkpoints: "idle" after a batch commits, "pending" with
the batch's snapshot_at after one fails, and "idle" on a throttled tick
when there is nothing to send. prune/1 refuses to touch receipts unless
every one of those rows says either "idle, and I said so after the cutoff" or
"pending, with a batch taken after the cutoff".
Three refusals are deliberately stricter than "check the rows that are there":
- no rows at all refuses, when there is a receipt older than the cutoff. A fleet that has not yet been upgraded writes no heartbeats, and "nobody is reporting" is not evidence that nobody is holding a batch.
- a missing
aurora_meter_checkpointstable refuses, for the same reason: it is a database below core schema version 7 and it has receipts. - an unparseable
pending_sincerefuses rather than being ignored.
A node that is genuinely gone is cleared with forget_node/1, deliberately
and one node at a time. That is the only override, and it overrides the rule
for one node rather than removing the rule.
Clocks, and the bound this module relies on
AuroraMeter.Checkpoints' documentation says the heartbeat is not a lease and
that nothing in this package subtracts it from anything, because
open-findings.md X100 measured clock_timestamp() stepping backwards nine
times in 300 seconds, worst 439 ms. This module does subtract it from
something, so the distinction has to be exact rather than implied.
That rule is about exclusion at seconds scale: deciding that a runner is
gone so that its work may be taken. No N a person would pick survives a
439 ms step there, and the sound mechanism has no clock in it at all
(AuroraMeter.Checkpoints.claim/3).
This decision is a different one. The smallest window it will act on is
one day, refused below that at boot (AuroraMeter.Config), which is about
200,000 times the measured backwards step of the shared clock and about 30,000
times the worst measured step of a node's own (2.6472 s, X59). For the answer
to flip, a row would have to sit within a few seconds of a cutoff a day or
more wide, and the row that then loses its protection is one whose node was
reporting idle at almost exactly the cutoff instant, so the batch it could
retry is itself within seconds of the cutoff. The durations rule in
architecture-map.md section 3 says exactly this: unsafe at seconds, safe at
minutes and hours. A day is neither of the cases it warns about.
One of the three stamps is the database's own: aurora_meter_checkpoints'
updated_at is written with clock_timestamp() in the statement, so the
staleness half of the receipt rule compares two readings of one clock and the
439 ms figure is the whole of its error.
The other two are a node's wall clock, and they are named rather than glossed.
aurora_meter_flush_receipts.inserted_at is stamped by the node that flushed
(AuroraMeter.Storage.Ecto.flush_batch/3, which says why it is not the
database's), and the heartbeat's pending_since is when a node took a batch
out of its own ETS, which has no database in it by design and must not
acquire one on the flush path. For either of those to produce a wrong answer,
a node's clock would have to disagree with the database's by more than the
whole window, which at the one day floor is 288 times the largest
disagreement anything else in this system tolerates: events_future_tolerance
refuses an event 300 seconds ahead, and Stripe's webhook signature window is
the same 300 seconds. A fleet whose clocks are a day apart has already stopped
being able to record events or accept webhooks. That is the bound, and it is
stated rather than assumed.
Sizing, and what to do before the first prune
A node under continuous traffic writes one receipt per flush_interval, which
at the 5,000 ms default is 17,280 rows per node per day. See
Retention for the measured table and index sizes, the archive
guidance, and the order to do an upgrade in: deploy, let every node write a
heartbeat, confirm them with status/0, and only then schedule the worker.
Running it
AuroraMeter.Oban.Retention is the optional worker. Nothing runs without a
host scheduling it, and a host that schedules nothing keeps every row for
ever, which is what task 03.09 means by "retained indefinitely".
Summary
Types
One node's flush heartbeat, as status/0 reports it.
Why one table was not pruned.
An allow-list key. The map may gain keys in a later release.
Functions
Removes one node's "flush:<node>" heartbeat, so it stops blocking a receipt
prune.
This node's identity in its flush heartbeat row.
The AuroraMeter.Operations name one table's prune pauses and resumes under.
Counts what prune/1 would delete, and writes nothing.
The tables prune/1 must never delete a row from.
Deletes what plan/1 counted, in bounded batches, and returns how many rows
went per table.
Every node's flush heartbeat, whether it blocks a receipt prune, and why.
Types
@type heartbeat() :: %{ node: String.t(), state: String.t(), updated_at: DateTime.t(), pending_since: DateTime.t() | nil, batch_id: String.t() | nil, version: String.t() | nil, blocks: boolean(), why: atom() }
One node's flush heartbeat, as status/0 reports it.
Why one table was not pruned.
@type report() :: %{optional(table()) => non_neg_integer()}
Rows counted (plan/1) or deleted (prune/1), per allow-list key.
@type table() :: :flush_receipts | :replay_checkpoints
An allow-list key. The map may gain keys in a later release.
Functions
Removes one node's "flush:<node>" heartbeat, so it stops blocking a receipt
prune.
This is the only override of the receipt rule, and it overrides it for one
named node rather than removing it. Use it when a node is genuinely gone:
scaled down, terminated, replaced, its machine destroyed. Returns
{:error, :not_found} rather than :ok when there is no such row, so a typo
cannot look like success.
The precondition, in plain words
The node must not be coming back with its ETS tables intact. If it is merely partitioned, or stopped and about to be restarted from the same memory, it may still hold a flush batch. Forgetting it removes the protection for that batch, and if the node then returns and retries it, the batch's usage is added a second time.
It logs at :warning with the row it removed, because a deletion that cannot
be undone should leave a trace an operator can find afterwards.
Examples
AuroraMeter.Retention.forget_node("app@10.0.1.7")
#=> {:ok, %{node: "app@10.0.1.7", state: "pending", ...}}
@spec node_id() :: String.t()
This node's identity in its flush heartbeat row.
:flush_node_id when the host set one, and to_string(node()) otherwise.
Examples
iex> is_binary(AuroraMeter.Retention.node_id())
true
The AuroraMeter.Operations name one table's prune pauses and resumes under.
One operation per table, because pausing receipt pruning while leaving the replay rows alone is something an operator actually wants.
Examples
iex> AuroraMeter.Retention.operation(:flush_receipts)
"retention:flush_receipts"
Counts what prune/1 would delete, and writes nothing.
Returns {:ok, report}, or {:blocked, report, reasons} when at least one
requested table could not be pruned. Being blocked is a normal outcome an
operator should see, not a failure a job should retry, so it is not an error
tuple. report still carries the counts for the tables that are prunable.
Options:
:only- allow-list keys to consider (default: all of them). Narrows; never widens.:older_than- aDateTimecutoff overriding every configured window, for an operator running a one-off. Still subject to every state predicate and to the receipt rule.
Examples
{:ok, report} = AuroraMeter.Retention.plan([])
Map.keys(report)
#=> [:flush_receipts, :replay_checkpoints]
@spec protected() :: [String.t()]
The tables prune/1 must never delete a row from.
Examples
iex> "aurora_meter_events" in AuroraMeter.Retention.protected()
true
Deletes what plan/1 counted, in bounded batches, and returns how many rows
went per table.
Returns {:ok, report} or {:blocked, report, reasons} on the same terms as
plan/1. A table that an operator has paused
(AuroraMeter.Operations.pause("retention:flush_receipts")) contributes a
:paused reason and whatever the batches before the pause removed.
Options: :only and :older_than as for plan/1, plus
:batch_size(default 1000) - rows perDELETE.:max_items(default 50000) - rows one call removes per table before returning with work still waiting. The next run continues.
Every batch is one autocommitted statement. Nothing here takes a lock of its own, opens a transaction or runs inside one.
Examples
{:ok, report} = AuroraMeter.Retention.prune(only: [:replay_checkpoints])
is_integer(report.replay_checkpoints)
#=> true
@spec status(keyword()) :: %{ cutoff: DateTime.t() | nil, heartbeats: [heartbeat()], versions: [String.t()] }
Every node's flush heartbeat, whether it blocks a receipt prune, and why.
This is what an operator reads before scheduling retention for the first time: one row per node, each carrying the package version that node is running.
Returns %{cutoff: DateTime.t() | nil, heartbeats: [heartbeat()], versions: [String.t()]}. cutoff is nil when aurora_meter_checkpoints does not
exist, which is every database below core schema version 7.
Examples
status = AuroraMeter.Retention.status()
is_list(status.heartbeats)
#=> true
@spec tables() :: [table()]
The allow-list keys, in the order plan/1 and prune/1 process them.
Examples
iex> AuroraMeter.Retention.tables()
[:flush_receipts, :replay_checkpoints]