The export outbox

Everything Aurora Meter Pro sends to Stripe as usage goes through one table, aurora_meter_outbox_items, and one state machine. This page is the machine.

The shape of it is ordinary: a producer writes an immutable row saying what should be sent, and a consumer sends it and records what happened. What makes it worth a page is the set of things it refuses to do, each of which was a way the previous design could bill a customer twice.

The row

Column What it is
tenant_key, subject_kind, subject_ref, exporter, destination_account, mode, mapping_version the identity, unique in the database
payload the financial intent: customer, meter, value, timestamp, identifier, feature, period. Immutable
state pending, leased, accepted, confirmed, uncertain, rejected, quarantined
attempts, next_attempt_at, first_attempt_at how many tries, when the next one is due, when the first one happened
lease_owner, lease_expires_at, lease_token who holds it, until when, and the fence
provider_ref, accepted_at, confirmed_at what the provider called it and when
last_error, quarantine_reason sanitised: a code, a status and at most 200 characters of message

subject_ref for a buffered usage window is "usage:<feature>:<period_start>:<from>:<to>". Both bounds are in it on purpose: restaging an unacknowledged window is the same identity and collapses into the existing row, while a later window after an acknowledgement is a different identity with a different idempotency key.

The idempotency key is a pure function of the identity:

identifier = "aurora_" <> sha256(subject_ref | account | mode | mapping_version)

71 bytes, comfortably inside Stripe’s 100 character limit for a meter event identifier. It carries no nonce, deliberately. The old reporter hashed a fresh UUID into it, because its design re-staged from fresh usage after a rejection and needed a new key for the replacement. In this design rejected is terminal and a new identity comes only from a new subject_ref, so a nonce would not merely be unnecessary: it would make every retry a second charge.

The states

              enqueue (eligible)              enqueue (ineligible)
                     |                                 |
                     v                                 v
 +---------------> pending --lease--> leased --accept--> accepted --confirm--> confirmed
 |                   ^                 | | |
 |  retry(delay)     |                 | | +--reject------> rejected    (terminal, visible)
 +-------------------+                 | +----uncertain---> uncertain <--+ (cap or horizon)
                                       |                       |
                                       +--quarantine--> quarantined --requeue--> pending
                                                                       ^
                                                          uncertain --recovery--+
# From To Trigger Predicate A stale writer sees
T0 none pending producer, eligible INSERT ... ON CONFLICT (identity) DO NOTHING zero rows inserted; it reads the existing id and proceeds with it
T0q none quarantined producer, ineligible the same insert with a reason the same
T1 pending or expired leased leased a deliverer claims SELECT ... FOR UPDATE SKIP LOCKED, then WHERE id = ? AND ((state = 'pending' AND next_attempt_at <= now) OR (state = 'leased' AND lease_expires_at < now)) nothing at all; somebody else has it. Not an error
T2 leased accepted :accepted or {:accepted, ref} WHERE id = ? AND lease_token = ? AND state = 'leased' zero rows, {:error, :stale_lease}, stale_worker telemetry, no retry of the write
T3 leased pending {:retry, delay} inside the cap and the horizon as T2 as T2
T4 leased uncertain :uncertain, or a retry past the cap, or a retry past the horizon as T2 as T2
T5 leased rejected {:rejected, reason} as T2 as T2
T6 leased quarantined a mapping, mode or account problem found at claim time as T2 as T2
T7 accepted confirmed the reconciler has provider evidence WHERE id = ? AND state = 'accepted' zero rows; it re-reads and reports
T8 uncertain accepted, pending or rejected AuroraMeter.Pro.Recovery with an expected-state map WHERE id = ? AND state = 'uncertain' AND lease_token = ? {:error, {:expected_state_mismatch, current}}
T9 quarantined pending an operator requeues after fixing the cause WHERE id = ? AND state = 'quarantined' AND mapping_version = ? {:error, :mapping_changed}

Four properties make the machine safe, and each is there because its absence was a defect.

attempts is incremented at claim time, not at outcome time. A worker that dies between claiming and answering has still consumed an attempt, so a crash loop cannot retry for ever.

first_attempt_at is set on the first claim and never moved. The horizon is measured from it, so it is a property of the item rather than of whichever process happens to be handling it.

rejected is terminal and visible. Nothing in the automatic path leaves it. Only an operator can, and only to record an abandonment.

The attempt cap never produces rejected. Exhausted work becomes uncertain, which is queryable and recoverable. Exhaustion is not evidence that the provider refused anything.

The lease and the fence are two different things

This is the part worth reading twice, because they look alike and only one of them is a correctness property.

Who gets an item is decided by SELECT ... FOR UPDATE SKIP LOCKED inside the claiming transaction. Two workers scanning the same page do not queue behind each other and do not both take a row: the second skips what the first holds. No timestamp is compared to decide it.

Whose write wins is decided by lease_token, a counter incremented on every claim. Every transition out of leased carries AND lease_token = ? AND state = 'leased', so a worker whose item was re-claimed under it writes zero rows and is told so. No timestamp is compared to decide that either.

lease_expires_at decides only when an abandoned item becomes available again. It is the one duration comparison left, and it is a liveness input. The database clock is shared across nodes but it is not monotonic: measured on this hardware, clock_timestamp() stepped backwards nine times in 300 seconds, worst case 439 ms. Against a 120 second lease that is a factor of 273, and the worst a step can do is move a re-claim by 439 ms in either direction. A re-claim at the wrong moment is not a hazard: the previous owner’s write is fenced out, and the resend carries the same identifier, so the provider deduplicates it. There is no arrangement of the clock that produces a double delivery, because no delivery decision reads the clock.

Where a duration genuinely is compared (the lease, the backoff schedule and the idempotency horizon), both sides are clock_timestamp() inside the same statement. Nothing reads the time into Elixir and writes it back.

Retry and backoff

backoff(attempts) = min(cap, base * 2 ^ (attempts - 1)) + jitter

with jitter up to a quarter of the delay. Defaults: base 5 seconds, cap 900 seconds, 20 attempts, which puts the twentieth attempt about four hours after the first and comfortably inside the horizon, so the cap is normally reached first and the horizon is the backstop.

The jitter is not decoration. Without it every item that failed in one batch retries in the same second as every other, which is how a recovering provider is rate limited straight back into failure.

A rate limit uses outbox_rate_limit_delay_seconds (default 30) rather than the exponential backoff, because Stripe’s per-endpoint limit is 25 requests per second and backing off by five seconds is not an answer to being over it.

Quarantine

quarantined is not a hold the system releases on its own. It is an item the software has decided it must not guess about. The reasons:

Reason Means
feature_buffered, attribution_unresolved, original_ineligible core already decided the event is not a candidate
feature_unmapped the feature has no entry in stripe_meters
no_customer the tenant has no provider_customer_id
no_account :stripe_account_id is not configured
mode_mismatch, account_mismatch the item was staged for a different Stripe account or mode from the one this node is configured for
period_buffered the period is before the feature’s cutover watermark, so the buffered reporter owns it
timestamp_window the occurrence is outside what the exporter says the provider accepts

An ineligible event is staged anyway, quarantined, never dropped. That matters most for a correction: a correction that quietly disappears is a customer credited nothing and told otherwise.

Watching it

[:aurora_meter, :pro, :outbox, :gauge] fires once per deliverer run with a count for every state, oldest_pending_age_seconds, and legacy_pending. Alert on uncertain, rejected and quarantined being above zero, and on oldest_pending_age_seconds climbing: all three need a human.

[:aurora_meter, :pro, :outbox, :deliver] fires per item with duration_ms and the state it landed in. It carries no tenant key: that is unbounded cardinality and a metrics backend would either drop the series or bill for it.

[:aurora_meter, :pro, :outbox, :stale_worker] fires when a conditional update affects zero rows. A few are normal on a cluster. A steady stream means leases are expiring while work is still in flight, so raise outbox_lease_seconds.

See usage reporting for how usage gets into the outbox, and recovery for what to do about an item that needs a human.