Lesson 40Scale and concurrencyAdvanced

Rebuilding from the ledger

When the cache corrupts, the ledger lets you start over.

By Solomon Ajayi · Free to read, no signup

Sometime later you discover the wallet.balance cache is wrong. Maybe a migration ran half-finished. Maybe a race condition slipped through. Maybe an engineer ran an ad-hoc UPDATE in a panic and forgot to also write the matching journal entry. The cache says ₦5,000 for a user; the ledger sums to ₦4,800. You can't trust the cache anymore. The recovery procedure: TRUNCATE the projection, scan the ledger account by account, sum debit and credit lines, write fresh balances back. The cache is in sync again. **The fact that this rebuild is POSSIBLE is the entire value of having a ledger.** Apply the entries below, then read the takeaway for the rebuild SQL.

Caches drift. A migration runs half-finished, a race slips through, an engineer fires an ad-hoc UPDATE in a 3am panic and forgets the matching journal entry. Now wallet.balance says ₦5,000 and the ledger sums to ₦4,800, and you have to decide which one to believe. Because the cache was always just a projection, the answer is never in doubt: the ledger is the truth and the cache is wrong.

The recovery is almost anticlimactic. You take a lock so no new entries sneak in mid-rebuild, then recompute every wallet balance straight from journal_line: for the User Wallet liability it is SUM(credit) minus SUM(debit) over its account code, and you write that back. The deposit, the service charge, the refund all replay into the same number the ledger has always implied. One UPDATE and the cache is honest again.

This is what double-entry buys you that wallet-balance-as-truth never could. If your only record were the number on the row and it got corrupted, there is nothing to recompute from and the money is genuinely lost. With the ledger as source of truth, every cache is disposable; corruption becomes an operational hiccup you fix with a script rather than a forensic investigation you may never close.

Worked example, step by step

Deposit ₦20,000

Normal deposit. The cache and the ledger agree at ₦20,000.

Deposit ₦20,000
AccountDebitCredit
FBO at Sponsor Bank (1300)₦20,000.00
User Wallet (2000)₦20,000.00

FBO UP ₦20,000. User Wallet UP ₦20,000. Standard funding entry. Imagine this is one of many entries posted over 6 months.

Service charge ₦500

User pays a service fee.

Service charge ₦500
AccountDebitCredit
User Wallet (2000)₦500.00
Fee Revenue (4400)₦500.00

User Wallet DOWN ₦500. Fee Revenue UP ₦500. Cache and ledger both walk to ₦19,500.

Refund ₦2,000

Customer service issued a refund. Wallet up.

Refund ₦2,000
AccountDebitCredit
User Wallet (2000)₦2,000.00
FBO at Sponsor Bank (1300)₦2,000.00

User Wallet UP ₦2,000. FBO DOWN ₦2,000 (refund payout). Switch to the Statement view and pick User Wallet, running balance: 20,000 → 19,500 → 21,500. THAT is the truth. If wallet.balance reads anything else, the cache lied. The rebuild procedure recomputes this same number from the ledger.

The rebuild. One UPDATE. The ledger reseeds the cache.

-- Lock the wallets you're about to rewrite, so concurrent posts wait.
BEGIN;
SELECT user_id FROM wallet FOR UPDATE;

-- Recompute every wallet balance from the ledger. Liabilities are
-- credit-positive, so balance = SUM(credit) - SUM(debit) for the linked
-- account code. (Assets flip the sign; do the same scan with the other
-- side for asset accounts.)
UPDATE wallet w
   SET balance = COALESCE((
         SELECT SUM(jl.credit) - SUM(jl.debit)
           FROM journal_line jl
          WHERE jl.account_code = w.account_code
       ), 0),
       updated_at = now();

COMMIT;

Same rebuild, scriptable for ops use.

def rebuild_wallet_balances(conn) -> None:
    with conn.transaction():
        # Lock every wallet row so concurrent posts wait.
        conn.execute("SELECT user_id FROM wallet FOR UPDATE")

        conn.execute(
            """
            UPDATE wallet w
               SET balance = COALESCE((
                     SELECT SUM(jl.credit) - SUM(jl.debit)
                       FROM journal_line jl
                      WHERE jl.account_code = w.account_code
                   ), 0),
                   updated_at = now()
            """
        )

Same rebuild, callable from an ops command.

func RebuildWalletBalances(ctx context.Context, db *sql.DB) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    if _, err := tx.ExecContext(ctx, `SELECT user_id FROM wallet FOR UPDATE`); err != nil {
        return err
    }

    if _, err := tx.ExecContext(ctx, `
        UPDATE wallet w
           SET balance = COALESCE((
                 SELECT SUM(jl.credit) - SUM(jl.debit)
                   FROM journal_line jl
                  WHERE jl.account_code = w.account_code
               ), 0),
               updated_at = now()
    `); err != nil {
        return err
    }
    return tx.Commit()
}

Takeaway

Rebuild is just SQL: `UPDATE wallet SET balance = (SELECT COALESCE(SUM(credit) - SUM(debit), 0) FROM entry_line el JOIN journal_entry je ON je.id = el.entry_id JOIN ledger_account la ON la.id = el.account_id WHERE la.id = wallet.account_id);` Run it under a lock. The cache is now in sync. The fact that you CAN run this, that the ledger is complete and immutable enough to reconstruct any projection from scratch, IS the value proposition of accounting double-entry over wallet-balance-as-truth. Without it, you can't recover from cache corruption. With it, you always can.

The code behind it

Reference solution

-- Rebuild wallet balances from the ledger.
-- COALESCE(..., 0) catches the SUM-over-zero-rows = NULL case so brand
-- new wallets (no ledger entries yet) land at 0, not NULL.
UPDATE wallet w
   SET balance = COALESCE((
         SELECT SUM(jl.credit) - SUM(jl.debit)
           FROM journal_line jl
          WHERE jl.account_code = w.account_code
       ), 0),
       updated_at = now();

Practice this on a real ledger

Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.

More in this section

Search lessons

Type to find any of the 85 lessons. Press Enter to open.