Committing the balance, risking the ledger
I wrapped a balance update and an audit log write in one transaction because that's what consistency is supposed to look like. Then a log insert failed and rolled back a reward the user should have already had.
By Meet ModiCrediting a reward touches two things: the number the user sees as their balance, and a row in an audit log explaining why the balance moved. I wrapped both in one transaction. A schema mismatch in the log table rolled back a balance credit that had, by every reasonable definition, already happened.
Users complained they didn't get a reward the app had already shown them as pending. The reward was real. My transaction had just decided the log entry mattered as much as the money.
The bug: I treated a low-stakes write and a high-stakes write as equally important because they were both writes. They weren't equally important.
Take 1: one transaction for consistency
async function creditReward(userId: string, amount: number, reason: string) {
await db.transaction(async (tx) => {
const wallet = await tx.wallets.findByUserId(userId);
await tx.wallets.update(userId, { balance: wallet.balance + amount });
await tx.auditLog.insert({
userId,
amount,
reason,
createdAt: new Date(),
});
});
}This looked correct. Balance and audit trail move together, so they can never disagree. But "never disagree" cuts both ways: if the audit insert throws because of a transient connection drop or a column that doesn't match, the whole transaction unwinds, including the balance update that had no problem at all.
The log write, the least important part of this function, had a veto over the most important part.
Take 2: commit the balance, guard the log separately
The balance update didn't need a transaction in the first place. It's a single atomic increment operation against the database, not a read-then-write cycle, so it's already safe under concurrent credits to the same user without any wrapping.
async function creditReward(userId: string, amount: number, reason: string) {
await db.wallets.incrementBalance(userId, amount);
try {
await db.auditLog.insert({
userId,
amount,
reason,
createdAt: new Date(),
});
} catch (err) {
logger.error("audit log write failed after balance credit", {
userId,
amount,
reason,
err,
});
}
}incrementBalance commits on its own. By the time the audit log insert even runs, the user already has their money, correctly, under concurrent load, no race window. If the log insert fails, the catch block logs it with enough context, userId, amount, reason, and the stack trace, for someone to reconcile later. The balance is never touched again by this function.
What I should have done first
I should have asked what a transaction is actually for before reaching for one. It's for making sure operations that must both succeed or both fail actually behave that way. The balance credit and the audit log don't have that relationship, one is the thing that matters and one is a record about the thing that matters, and no amount of transactional consistency changes which is which.
The instinct to wrap everything in a transaction because it feels safer was exactly backwards here. It made the risky part of the system depend on the safe part.
More Posts
The vector database was innocent
I built a RAG service over the OpenTelemetry docs, then pointed OpenTelemetry back at it to find out why answers took 16 seconds. It wasn't the LLM. It wasn't the vector search either.
A query that returned nothing nested inside it
The exact same logical query returned full nested data through the REST layer and empty shells through the service layer underneath it. Same fields requested. Same backend. One shorthand parameter that only one of the two actually understood.
One field, three different envelopes, and a fix that made it worse
The same subscription-status field came back nested two levels deep, one level deep, or bare at the top, depending on account state. My fix didn't catch that. It added math on top of a check that was already wrong.