Skip to main content

Atomic, overdraft-proof, idempotent credit metering over Postgres. Extracted from PANTHEON.

Project description

credit-ledger

tests PyPI Python License

An atomic, overdraft-proof, idempotent credit ledger — for metering money on a pay-per-use or multi-tenant service. ~150 lines of Python over Postgres. Extracted from PANTHEON, a multi-tenant AI substrate, where it's the money path for autonomous AI agents that spend real credits on every turn.

The interesting part isn't the size — it's that three properties that are usually gotten subtly wrong are each guaranteed by a single, boring database mechanism instead of application-level hope:

Property How Not by
No overdraft, ever — a balance can never go negative, even under a stampede of concurrent charges one guarded UPDATE … WHERE credits >= n SELECT then UPDATE (a race), or a mutex (doesn't survive multiple processes)
Exactly-once under retries — a replayed charge (a Stripe webhook fired twice, a client retry) charges once UNIQUE(tenant_id, idempotency_key) on an append-only ledger de-duping in app code (another race)
Tenant isolation — tenant A can't read or write tenant B's balance Postgres row-level security, FORCEd a WHERE tenant_id = ? you have to remember to add every time

The core: one statement does the hard part

A spend is a single guarded, atomic UPDATE:

UPDATE credit_balance
   SET credits = credits - :n
 WHERE tenant_id = :t AND credits >= :n
RETURNING credits;
  • The row lock serialises concurrent charges against the same tenant — no lost updates.
  • The credits >= :n guard makes overdraft structurally impossible: the charge either fully succeeds (a row comes back) or changes nothing (zero rows). There is no window between the check and the debit, because they're the same statement.

That's the whole trick, and the test proves it: fire 100 concurrent spends of 1 credit against a balance of 50, and exactly 50 succeed — the balance lands at 0, never negative, with 50 ledger rows.

# tests/test_metering.py
def test_atomic_decrement_under_concurrency(engine, tenant):
    metering.grant(tenant, 50, engine=engine)
    with ThreadPoolExecutor(max_workers=20) as ex:
        results = list(ex.map(lambda _: metering.decrement(tenant, 1, engine=pool).ok, range(100)))
    assert sum(results) == 50                              # exactly the balance, no more
    assert metering.balance(tenant, engine=engine) == 0.0  # never negative

Idempotency: the ledger is the dedup

Every grant and spend appends a row to credit_ledger, which carries a UNIQUE(tenant_id, idempotency_key). Pass an idempotency_key (a Stripe event_id, a request id) and a replay can't insert a second row — the database rejects it, and the code returns the original result marked deduped=True. Money is never double-counted, and a duplicate never surfaces as a 500.

The subtle bit is the concurrent race: two identical charges arrive at once, both past the "already seen?" check, both try to insert. One wins; the other's INSERT violates the constraint, which rolls back its whole transaction — including the decrement. The loser then re-reads the ledger, finds the winner's row, and returns that. So a concurrent duplicate is a clean dedup, not a double-charge and not an error. There's a test for exactly this — 20 concurrent replays of one Stripe event → one credit applied, zero exceptions.

The correctness detail most people miss: fail loud, not phantom-success

An IntegrityError after retries is ambiguous — it could be a genuine concurrent dedup (the winner committed, there's a ledger row), or it could be a foreign-key violation because the tenant doesn't exist (nothing landed). Reporting success in the second case would silently lose money. So the code disambiguates by re-reading the ledger: a row means real dedup → report success; no row means nothing happened → fail loud (ok=False). This came out of an adversarial self-audit of the substrate; it's the difference between "looks fine" and "correct."

The RLS footgun this quietly avoids

Row-level security is only a guarantee if the writer is actually subject to it. Metering runs on a privileged app role (the money path is infrastructure), and on any managed Postgres that role is not BYPASSRLS. So before every transaction the code binds the tenant into a session variable the policy reads:

c.execute(text("SELECT set_config('app.current_tenant', :t, true)"), {"t": tenant_id})

Skip this and, the moment RLS is enforced in production, the guarded UPDATE matches zero rows and the INSERT's WITH CHECK fails — billing silently bricks, while every dev box (superuser, RLS bypassed) looks perfectly fine. Getting this right is the difference between a demo and something you'd run.

Why so little code

The database does the concurrency, the atomicity, and the isolation. The code's entire job is to use those primitives correctly — bind the tenant, guard the decrement, key the ledger, and disambiguate the one genuinely-ambiguous failure. Fewer moving parts is the point, not an accident.

Use it

pip install -e .                       # SQLAlchemy is the only runtime dep
createdb ledger && psql "$DATABASE_URL" -f schema.sql
from credit_ledger import metering

metering.grant(tenant_id, 100, idempotency_key="stripe:evt:abc")  # top up (Stripe-retry-safe)
c = metering.decrement(tenant_id, 1, reason="chat-turn")          # spend
if not c.ok:
    ...  # out of credits — nothing was charged
metering.balance(tenant_id)                                       # -> Decimal (exact, never float)

Every function also takes an explicit engine= (for your own pool/lifecycle). schema.sql is the full Postgres schema including the RLS policies.

Run the tests

DATABASE_URL=postgresql://localhost/ledger pytest -q     # needs Postgres + schema.sql applied

The suite is the spec: overdraft impossibility under 100-way concurrency, idempotent spend and grant retries, concurrent Stripe-replay dedup, and rejection of non-positive grants (a negative "grant" would decrement a tenant — the primitive refuses it before touching the DB).

Benchmark: does it hold at load?

There's a reproducible load benchmark in benchmarks/ — it fires thousands of concurrent charges at a single balance row (the worst case: every charge serialises on the same lock) and re-checks the invariant afterwards. A real run on stock Postgres 16 in Docker (durable commit per charge, 32 workers), 10,000 charges against a balance of 5,000:

throughput   ~1,100 charges/sec        p50 ~22 ms · p95 ~77 ms · p99 ~138 ms
succeeded     5,000  (= the balance, exactly)      rejected 5,000 (insufficient)
ledger rows   5,000  (one per real debit)          final balance 0.0 (never negative)
INVARIANT HELD ✓

That's the pessimal number — one contended row, throughput bounded by a single durable round-trip. Charges spread across many tenants hit different rows and don't contend, so real throughput scales with Postgres, not this ceiling. The point isn't the speed; it's that under 10,000 racing charges colliding at the empty-balance boundary, not one overdrafts. See benchmarks/README.md for how to read it and run your own.

Design notes

A few deliberate choices, and the answers to the sharp questions they invite:

  • A failed (insufficient) charge records nothing — so a retry with the same key re-attempts. That's intended. Idempotency dedups successful effects, not attempts. A charge rejected for insufficient balance didn't happen, so the same key should be free to succeed later — e.g. a metered webhook that hit a zero balance and is retried after a top-up. The dedup keys the committed ledger row, which only exists for charges that actually landed.
  • numeric(18,4) in the database, Decimal at the API boundary (since 0.2.0). The stored balance and every ledger delta are exact decimals — and amounts are coerced to Decimal and bound as Decimal (never handed to Postgres as a float to cast), and balances are returned as Decimal. So money is exact end to end: fractional per-call pricing, balances past 2⁵³, and arithmetic on the returned value all stay precise. Pass amounts as Decimal/int/str (a float is accepted and coerced via Decimal(str(x)), but prefer str/Decimal so you never introduce float error before it reaches us).
  • Why two attempts is enough (for _attempt in range(2)). Under N concurrent charges sharing one key, exactly one wins the UNIQUE(tenant_id, idempotency_key) insert and commits; every loser's INSERT raises, rolling back its whole transaction (decrement included). On the second pass the losers see the winner's now-committed ledger row in the "already seen?" read and return that — a clean dedup. A third attempt can't be needed: once the winner commits, the row is visible, so attempt two is always either the winner's own success or a loser's dedup. The re-read after the loop only exists to distinguish the genuinely-nothing-landed case (e.g. a foreign-key violation) — so a constraint error is never reported as phantom success.

Context

This is one self-contained piece of PANTHEON — a multi-tenant substrate for running AI agents safely on other people's money and data (tenancy + RLS isolation, a governed reasoning loop, an approval queue, this metering ledger, and a quality gate), designed and built solo. The live Studio at pantheonlabs.info runs on it; the flagship write-up is at pantheonlabs.co.uk. Built by Isaac Teague Frayling.

Changelog

  • 0.2.0Decimal end to end (breaking): balances return Decimal and amounts are coerced to and bound as Decimal, instead of round-tripping money through float. The numeric(18,4) column was always exact; now the API boundary is too, so fractional pricing / large balances / arithmetic on the result can't pick up IEEE-754 error. float inputs are still accepted (coerced via Decimal(str(x))).
  • 0.1.0 — initial release.

License

Apache-2.0. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

credit_ledger-0.2.0.tar.gz (14.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

credit_ledger-0.2.0-py3-none-any.whl (13.9 kB view details)

Uploaded Python 3

File details

Details for the file credit_ledger-0.2.0.tar.gz.

File metadata

  • Download URL: credit_ledger-0.2.0.tar.gz
  • Upload date:
  • Size: 14.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for credit_ledger-0.2.0.tar.gz
Algorithm Hash digest
SHA256 246a5c04d4b20349d6c5681dd3362c8ffe7f9c12fd76309d124fc57e6bf5a7d0
MD5 e5c2f2dd5b6882540b787c35ee598d65
BLAKE2b-256 6281a4fbb78a07ed49cf430fb3eee85cb81f44ac487c3e9da39e5236077025bf

See more details on using hashes here.

Provenance

The following attestation bundles were made for credit_ledger-0.2.0.tar.gz:

Publisher: publish.yml on Igfray/pantheon-credit-ledger

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file credit_ledger-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: credit_ledger-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 13.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for credit_ledger-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3af25fd9a0654e507afddb2168410e76f4e3843ac9960f008e131c646c21cda4
MD5 72a505aabf7772958fe24dab230b676e
BLAKE2b-256 3058bda6f61c1df493314f3a8a484e3ba76aa29f6c6971ab7b4e565f2bbc7f59

See more details on using hashes here.

Provenance

The following attestation bundles were made for credit_ledger-0.2.0-py3-none-any.whl:

Publisher: publish.yml on Igfray/pantheon-credit-ledger

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page