DEV Community

Indie Rob
Indie Rob

Posted on AI-assisted

credits_remaining -= 1 is a race condition

A credits_remaining: int column on your users table looks perfectly fine for an AI SaaS. Sign a user up, drop 100 credits into their row, deduct on each request, top up on payment. Done.

It stays fine until the work you're charging for goes asynchronous. Then it becomes surprisingly easy to charge twice, overspend, or lose track of why a balance changed.

I built it the naive way first. Here is the failure mode I ran into, and the pattern I ended up shipping.

The race

Imagine a user has 10 credits. Two API requests arrive almost simultaneously, each costing 7 credits' worth of compute.

Both handlers read the balance:

balance = await db.fetch_val(
    "SELECT credits_remaining FROM users WHERE id = :id",
    {"id": user_id},
)
# 10
Enter fullscreen mode Exit fullscreen mode

Both check 10 >= 7. Both decide the user can afford the job. Both enqueue the work.

You just sold 14 credits of compute for 10.

If your compute costs real money — a hosted LLM call, a GPU minute, a paid third-party API — you are eating the delta. If it costs enough, that is a real leak.

"But I'll wrap it in a transaction"

Common first fix. Doesn't do what most people think.

Postgres defaults to READ COMMITTED isolation. Under READ COMMITTED, two concurrent transactions can each see the pre-spend balance before either commits. Both read 10, both decide 7 is affordable, both write back a new balance of 3 — the second overwriting the first. Net effect: same overspend, and you have thrown away one of the deductions on top of it.

SERIALIZABLE isolation would catch this. It also turns every write in your application into something that might fail with a serialization error and need a retry loop, whether it has anything to do with the billing race or not. Most teams don't want that as a global setting.

The narrower fix: lock the row for the spend

The tighter answer is to serialize only the read-and-write for the balance being spent from.

async with db.transaction():
    balance = await db.fetch_val(
        "SELECT credits_remaining FROM users WHERE id = :id FOR UPDATE",
        {"id": user_id},
    )
    if balance < cost:
        raise InsufficientCredits()
    await db.execute(
        "UPDATE users SET credits_remaining = credits_remaining - :cost "
        "WHERE id = :id",
        {"id": user_id, "cost": cost},
    )
Enter fullscreen mode Exit fullscreen mode

SELECT ... FOR UPDATE acquires a row-level lock. The second concurrent transaction blocks until the first commits, then sees the updated balance of 3 and correctly refuses the second 7-credit job.

That closes the race. Two different jobs can no longer overspend the same balance.

Another problem shows up almost immediately.

The second race: retries

Workers retry. Brokers redeliver. HTTP clients retry on 504. Users double-click. If the same logical job can enter your deduction path twice, your billing correctness now depends on every caller in the system behaving perfectly and never retrying a completed request.

That is a bad invariant to bet a billing system on.

The row lock doesn't help here. Each retry is its own transaction. It dutifully takes the lock, sees the current balance, and deducts again.

The fix is separate: an idempotency key at the deduction layer. Give every logical job a stable ID, and make the deduction insert unique on (user_id, job_id, kind). A retry that tries to re-insert the same fact gets rejected by the unique constraint, and the handler treats that rejection as "already recorded, carry on".

The distinction I originally missed

Row lock and idempotency key are both about "the same fact shouldn't be charged twice", but they solve different races:

  • Row lock prevents two different jobs from overspending the same balance.
  • Idempotency key prevents the same job from being charged twice.

I originally treated them as one problem. They aren't. You need both, and they compose cleanly in the same handler.

The pattern I ended up shipping: an append-only ledger

Once I accepted that "the balance" is derived state, not primary state, the schema simplified.

CREATE TABLE credit_entries (
    id          BIGSERIAL PRIMARY KEY,
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    delta       INTEGER NOT NULL,
    kind        TEXT NOT NULL,
    job_id      UUID,
    reason      TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (user_id, job_id, kind)
);

CREATE INDEX ON credit_entries (user_id, created_at);
Enter fullscreen mode Exit fullscreen mode

Every movement is a row:

grant           +100
deduct            -7
task_started       0
task_completed     0
refund            +7
Enter fullscreen mode Exit fullscreen mode

The balance is SUM(delta) WHERE user_id = ?. Never stored, always computed. If you're worried about the read cost, cache it — but the ledger stays the source of truth.

Spending looks like this:

async with db.transaction():
    balance = await db.fetch_val(
        """
        SELECT COALESCE(SUM(delta), 0)
        FROM credit_entries
        WHERE user_id = :id
        FOR UPDATE
        """,
        {"id": user_id},
    )
    if balance < cost:
        raise InsufficientCredits()
    try:
        await db.execute(
            """
            INSERT INTO credit_entries
                (user_id, delta, kind, job_id, reason)
            VALUES
                (:id, :delta, 'deduct', :job_id, :reason)
            """,
            {
                "id": user_id,
                "delta": -cost,
                "job_id": job_id,
                "reason": reason,
            },
        )
    except UniqueViolation:
        # (user, job, 'deduct') already exists. Retry is a no-op.
        pass
Enter fullscreen mode Exit fullscreen mode

Row lock handles the concurrent-jobs race. Unique constraint handles the retry race. Same handler. Both problems addressed, no special cases.

Why the ledger earns its weight

More machinery than a single integer column, obviously. It earns it in ways you feel every week once you have real users.

Refunds are new facts, not mutations. A failed job gets a +7 refund entry that references the original deduction. The original deduction stays. Nothing was rewritten. You can look back at the sequence and see exactly what happened.

Failed jobs remain explainable. The task_started and task_completed entries mean you can tell the difference between "we charged the user and the job succeeded" and "we charged the user and something died".

Reconciliation becomes possible. If workers crash between deducting and completing, you can find orphaned deductions programmatically — deduct entries without a matching task_completed or refund after some timeout — and repair them without opening the database by hand.

Support can answer "why is my balance 37?" They run one query and read the last N rows in chronological order. This is a real support cost you're paying whether you notice it or not.

Billing state has an audit trail by default, without you having to bolt on a separate audit log later.

When it is overkill

If your credits map 1:1 to synchronous API calls, no async work, no retries, no long-running compute, then no — you don't need this. A locked update on a column is fine.

The ledger earns its complexity when at least one of these is true: work happens off the request path, workers can retry, refunds are non-trivial, or support has to answer questions about historical balances.

If you're building an AI SaaS with billed LLM calls, all four are usually true from month one.

In production

I put the decision doc, a concurrency test receipt (real PostgreSQL sessions, two concurrent deducts of 7 on a starting balance of 10, asserting exactly one succeeds and the balance ends at 3), and a few other engineering excerpts into a public inspection repo for The Fabrica:

github.com/webrot9/thefabrica-inspect

Not the commercial source. Enough code and enough decisions to inspect whether the engineering claims hold up before deciding to license the full codebase. The ledger is one of the pieces I most wanted to expose there, because it's exactly the kind of thing that separates a starter kit from a codebase you can actually run a business on.

If you're building anything with async, billed work, do the ledger from day one. Retrofitting it after your first "sold 14 for 10" incident is not fun.

Top comments (0)