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

TableAgeStateWindow
aurora_meter_flush_receiptsinserted_atevery node's flush heartbeat proves no node holds an older batchflush_receipt_retention, 30 days
aurora_meter_checkpoints (only "events_replay:<generation>" rows)updated_atactivated or abandoned, and not a generation the live projection namesreplay_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_checkpoints table refuses, for the same reason: it is a database below core schema version 7 and it has receipts.
  • an unparseable pending_since refuses 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.

Rows counted (plan/1) or deleted (prune/1), per allow-list key.

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.

The allow-list keys, in the order plan/1 and prune/1 process them.

Types

heartbeat()

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

reason()

@type reason() :: %{table: table(), reason: atom(), detail: map()}

Why one table was not pruned.

report()

@type report() :: %{optional(table()) => non_neg_integer()}

Rows counted (plan/1) or deleted (prune/1), per allow-list key.

table()

@type table() :: :flush_receipts | :replay_checkpoints

An allow-list key. The map may gain keys in a later release.

Functions

forget_node(node_id, opts \\ [])

@spec forget_node(
  String.t(),
  keyword()
) :: {:ok, map()} | {:error, :not_found}

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", ...}}

node_id()

@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

operation(key)

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

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"

plan(opts \\ [])

@spec plan(keyword()) :: {:ok, report()} | {:blocked, report(), [reason()]}

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 - a DateTime cutoff 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]

protected()

@spec protected() :: [String.t()]

The tables prune/1 must never delete a row from.

Examples

iex> "aurora_meter_events" in AuroraMeter.Retention.protected()
true

prune(opts \\ [])

@spec prune(keyword()) :: {:ok, report()} | {:blocked, report(), [reason()]}

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 per DELETE.
  • :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

status(opts \\ [])

@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

tables()

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