Credits are a ledger, not a counter
Applies to The Fabrica v0.1.2. Documentation source:
1ee1bc9cb661, including post-release documentation corrections.
The competitor pattern
Other AI SaaS boilerplates ship something like this:
class User(Base):
has_access: bool = False
credits_remaining: int = 0# In the LLM endpoint:
if not user.has_access and user.credits_remaining < 1:
raise HTTPException(402, "Out of credits")
user.credits_remaining -= 1
session.commit()That works until the first time something interesting happens. Concretely:
| Failure mode | What goes wrong |
|---|---|
| Two concurrent requests | Both read credits_remaining=1, both decrement to 0, both succeed. User burned 2 generations for 1 credit. |
| Worker crash mid-task | Credit deducted, no work done, no refund — the user paid, you didn’t deliver. They dispute. You eat the chargeback. |
| Celery retry on transient failure | Task body runs twice, credit deducted once because the API handler already committed. User paid 1×, got 2 generations. |
| Refund dispute | “I never used the 87 credits you charged me for last month.” You have one column, no audit trail. Paddle sides with the user. You eat 87 credits. |
What we ship instead
credit_transactions is an append-only ledger. Every credit movement is one
INSERT — never an UPDATE.
CREATE TABLE credit_transactions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
delta INT NOT NULL, -- -1 for deduct, +1 for refund, 0 for lifecycle
kind VARCHAR(32) NOT NULL, -- deduct | refund | grant | task_started | task_completed | task_failed
job_id VARCHAR(128) NOT NULL, -- Celery job id (or minted UUID for one-shots)
reason VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (job_id, kind)
);Balance is SELECT SUM(delta) FROM credit_transactions WHERE user_id = $1.
Two mechanisms, two different jobs
Conflating these is the mistake worth avoiding, because each one leaves exactly the gap the other closes. There are no Postgres advisory locks anywhere in the ledger, and no SAVEPOINT.
UNIQUE (job_id, kind) makes one logical operation idempotent. A given
job produces at most one row of each kind, so a broker redelivery of the
same attempt cannot insert a second deduct. It does nothing for two
different jobs racing: each would read the same pre-deduct balance and
both could pass the check.
A FOR UPDATE row lock on the owning user makes the balance check
atomic. deduct_credits takes it before reading the balance and holds it
to the end of the transaction, so check-then-insert cannot interleave.
Concurrent spends on one account serialise; spends on different accounts
do not contend. Without it, two deducts with different job_ids each take
their READ COMMITTED snapshot before either commits, both see enough
balance, and the account goes negative.
The idempotency key has a sharp edge, and it is a design consequence rather
than a bug: reusing a job_id for a genuinely new chargeable attempt
returns the original movement and charges nothing. Mint a fresh job_id
per chargeable attempt; keep it stable only across transparent
redeliveries of the same attempt.
What each failure mode actually hits
- Two requests, same
job_id→ the second INSERT tripsIntegrityError; the caller rolls back, re-reads and returns the row that won. No double-spend. - Two requests, different
job_ids → the row lock serialises them. The second reads the balance the first committed and raisesInsufficientCreditsErrorif it no longer covers the spend. - Worker crash after deduct → the ledger has a
deductrow with no matchingtask_started. The reconciler runs every 60 seconds and refunds any deduct that has stayed orphaned forORPHAN_THRESHOLD_S(5 minutes), inserting areconciler_refundrow. - Celery retry →
record_task_started(job_id)is idempotent via the same UNIQUE constraint. Retry-safe. - Refund dispute → every movement has a timestamp, a reason and a linked
job_id. Answering is a query, not an investigation.
Trade-offs (we are honest)
This is more code than the has_access pattern. Specifically:
workers/credit_ledger.pyis a module with a spend path, a compensation path and the lifecycle markers, against ~20 lines for the simple approach.- Reconciler is a separate Celery beat task running every 60 seconds.
- Balance read is
SUM()over a table that grows ~3 rows per generation. Add an index, accept the cost.
One alternative approach uses the same append-only ledger with a users.credits_remaining integer column kept in sync via conditional UPDATE. That’s a third valid design — slightly simpler at the cost of a denormalised column that can drift. We chose pure event-sourcing because (a) it can’t drift, and (b) it composes cleanly with the AuditLog pattern we already ship for GDPR Art. 30.
Receipts
- Source:
backend/src/workers/credit_ledger.py - Row lock:
CreditTransactionRepository.lock_user_rowinbackend/src/services/repositories_async/credit_transaction.py - Idempotency key:
uq_credit_transactions_job_kindinbackend/src/models/credit_transaction.py - Reconciler:
backend/src/workers/credit_reconciler.py - Race-condition test:
test_concurrent_deducts_do_not_double_spendinbackend/tests/test_credit_ledger.py— two spends, differentjob_ids, so the unique constraint cannot help and only the row lock can
Provenance. Documentation source: 1ee1bc9cb6619c19f57766731e7884b37f515dc9 (main).
Extracted from docs/recipes/why-credit-ledger.md.
That commit is later than the release these pages describe: it carries documentation corrections made after thefabrica-v0.1.2 went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.