AuroraMeter.Events.Backfill (Aurora Meter v1.0.0)

View Source

Internal. The implementation behind mix aurora_meter.events.backfill. Not part of the supported surface; run the Mix task.

It gives every aurora_meter_events row written before core schema version 7 the identity that version 8 makes mandatory. Bounded, checkpointed and re-runnable.

What it writes, and what it approximates

For each row whose event_id is null:

  • event_id = "legacy:" <> id, derived from the row's own primary key, so two runs produce the same value for the same row and a re-run is a no-op.
  • occurred_at = inserted_at. This is an approximation. The row was written by AuroraMeter.track/4, which never recorded when the usage happened, only when the row reached the database.
  • kind = "usage", original_event_id = NULL, dimensions = '{}'.
  • period_start and period_source from AuroraMeter.Period.containing/2 at occurred_at.
  • payload_hash from the canonical tuple (AuroraMeter.Events.Canonical).
  • attribution = "resolved" when the period source placed the instant, and "unresolved" when it could not, in which case period_start is the calendar month containing the instant. That is the second approximation, and "unresolved" is how you find it.
  • plan_id and plan_version stay null. Legacy events were never billed and their plan attribution cannot be reconstructed.

How an operator tells an approximated row from an exact one

-- every row this task has ever touched: occurred_at is the write time,
-- not the usage time
SELECT count(*) FROM aurora_meter_events WHERE event_id LIKE 'legacy:%';

-- of those, the ones whose billing period is a guess as well
SELECT count(*) FROM aurora_meter_events WHERE attribution = 'unresolved';

A row recorded through the V1 durable path carries a caller-supplied event_id that cannot begin legacy: (the prefix is reserved) and an occurred_at the caller stated. The legacy: prefix is therefore the exact discriminator, and it is derived rather than flagged, so it cannot drift.

What it never writes

It writes no aurora_meter_event_totals row, enqueues no outbox item and emits no PubSub (L-03a-3). Legacy events were never billed, and upgrading one must not make it billable.

Exclusion between two runners

Two concurrent runs are safe, because the derivation is deterministic and every update carries WHERE event_id IS NULL; they are only wasteful. The task still refuses a second run, and it decides that with a Postgres advisory lock on a pinned connection, held for the length of the run.

A lock rather than a lease, deliberately. A lease is a duration compared against a clock, and clock_timestamp() is the database host's OS clock, which is corrected: it was measured stepping backwards 439 ms on a 32.5 second cadence on the development host (open-findings.md X100). A lock has no clock in it, and it releases itself when the connection holding it dies, which is exactly the signal a stale lease is trying to approximate.

The checkpoint row's state and updated_at are a report for an operator, not the decision. A "running" state with the lock free means a previous run was killed; the task says so and --force-resume proceeds.

One measured consequence of holding the lock on a pooled connection: when the runner is killed, Postgres releases the lock as the connection closes, and the pool closes it when it notices the client is gone. That is immediate for a killed OS process, and on this host it took under 50 ms for a killed BEAM process. So a resume attempted in the same instant as the kill can still see :already_running; a resume attempted by a human cannot.

Resuming

The cursor is %{"seq" => n} in aurora_meter_checkpoints["events_backfill"], advanced inside the same transaction as the batch it describes. A run killed between batches resumes at the last committed batch. The scan is ordered by seq, never by id: id is a random v4 UUID, so a forward keyset scan ordered by it can miss a row committed by a transaction that started earlier.

A row inserted by a still-running 0.4.x node during the backfill receives a higher seq than the cursor, so the same forward pass reaches it.

Summary

Types

The counts a run reports.

What a run returns.

Functions

The name of the checkpoint row this task keeps.

The event_id this task derives for a legacy row with the given primary key.

How many rows still have no event_id. What core schema version 8 refuses on.

Runs the backfill.

Types

counts()

@type counts() :: %{required(String.t()) => term()}

The counts a run reports.

result()

@type result() ::
  {:ok, counts()}
  | {:error, :already_running, counts()}
  | {:error, :stale_running, counts()}
  | {:error, :paused, counts()}

What a run returns.

:already_running means another process holds the advisory lock right now. :stale_running means the checkpoint says "running" but nothing holds the lock, so the previous run was killed: --force-resume proceeds from its cursor. The two are kept apart because only the second is a decision for a human.

Functions

checkpoint_name()

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

The name of the checkpoint row this task keeps.

Examples

iex> AuroraMeter.Events.Backfill.checkpoint_name()
"events_backfill"

legacy_event_id(id)

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

The event_id this task derives for a legacy row with the given primary key.

Deterministic, which is what makes a re-run a no-op.

Examples

iex> AuroraMeter.Events.Backfill.legacy_event_id("6d1f0b9c-0000-4000-8000-000000000001")
"legacy:6d1f0b9c-0000-4000-8000-000000000001"

remaining(opts \\ [])

@spec remaining(keyword()) :: non_neg_integer()

How many rows still have no event_id. What core schema version 8 refuses on.

Options: :repo.

run(opts \\ [])

@spec run(keyword()) :: result()

Runs the backfill.

Options:

  • :repo: the repo to work through. Defaults to the configured repo.
  • :batch_size: rows per transaction, default 5000.
  • :max_batches: stop cleanly after this many committed batches, leaving the checkpoint at the last one. For a bounded maintenance window.
  • :dry_run: scan and compute, write nothing at all, report the same counts. No row is updated and no checkpoint row is created or changed, so a dry run takes no lock and is safe beside a real one.
  • :force_resume: proceed although the checkpoint says "running". Only reachable when the advisory lock is free, which is to say when the previous runner is gone.
  • :timeout: milliseconds allowed for one statement and for one batch transaction, default 60000. Raise it with --batch-size, or a large batch on a busy database dies part way through.