Contents

Concurrency

Two operations touching the same account at once stay correct through one app-side rule and one database constraint: every operation locks its accounts in one global order, and a unique index makes a forked hash chain impossible.

A “deadlock” is the classic trap where two operations each hold something the other needs and neither lets go, so both wait forever. Taking every lock in the same fixed order is the textbook way to make that trap impossible, and that ordering rule is what this page is about.

Source src/ledger.ts#L84lockAllsrc/economy.ts#L920lockAccountssrc/engines/postgres.ts#L258advanceChaindb/postgresql-schema.sql#L91chain_links_account_prev_uq

When two operations touch the same account at the same time, they can interleave and corrupt a balance. They can also both extend that account’s hash chain from the same point, which forks it. economy-lab keeps concurrent writes correct with an application-side rule and a database constraint.

The idea

Before it posts, every operation acquires a lock on each account it will touch, in one global order: a plain sort of the account ids. Two operations that share an account acquire its lock in the same sequence, so one waits for the other.

lockAll de-duplicates the account set and sorts it, then takes each lock in turn:

for (const account of [...new Set(accounts)].sort()) {
  await ledger.lock(account);
}

Why the order has to be deterministic

Acquire locks in an arbitrary order and you get deadlocks: one operation locks A and waits for B, another locks B and waits for A. A shared total order removes the circular wait — both acquire A before B.

ARBITRARY ORDERtxn 1holds Atxn 2holds BABeach waitscircular wait — deadlockONE GLOBAL ORDERtxn 1A, then Btxn 2A, then BABwaits its turn at Aboth acquire A before B — no cycleThe database backstop below the locks: the unique index on (account_id, prev_hash) refuses a forked chain head; the loser retries.
A shared total order — a plain sort of the account ids — removes the circular wait: both takers acquire A before B, so one waits and neither deadlocks. Beneath the locks, the unique index on (account_id, prev_hash) refuses a forked chain head outright; the loser retries against the new head.

The sort has to be identical everywhere it runs. .sort() compares by code unit, which is stable across machines and runtimes. A locale-aware comparison is not: it can order the same two ids differently in two places, which reintroduces the cycle. lockAccounts routes every operation through lockAll before its handler runs, so no handler can reimplement the ordering incorrectly.

The no-fork constraint

Locking serializes contending operations, but the forked chain they could still produce has to be blocked at the database. Each account’s chain advances one link per posting, and a unique index on (account_id, prev_hash) prevents two postings from attaching at the same prior head.

Under a race, the losing insert hits that unique index and the engine raises a conflict. economy-lab classifies the conflict as transient and retries the whole unit of work; the retry re-reads the account’s new head and posts against it.

The hot-account ceiling

Per-account locking has a built-in ceiling: an account can only commit as fast as its one lock turns over, and the platform’s own accounts sit on the other side of every operation. Platform sharding is the pressure valve: each hot platform account can split into shard rows that concurrent postings spread across. Nothing on this page changes when it’s on — a shard row takes its own lock and extends its own chain like any other account, which is exactly the point of the split.

How the two layers divide the work

  • The lock ordering is the primary serialization. These writes run at the database’s default isolation level rather than SERIALIZABLE, so preventing contending operations from interleaving falls to the ordering rather than the engine.
  • The unique index enforces chain continuity in the database, and it covers the window before an account has a balance row, when the application lock has nothing to acquire. The conformance suite runs same-account operations in parallel and checks that they conserve value and never overdraw.

What relies on it

  • Every operation acquires its locks through lockAccounts before its handler runs, so this applies across the whole submit surface.
  • Integrity depends on it: the per-account chain stays linear only because the lock order and the unique index stop two writers from extending the same head.

Writes that survive the lock ordering and the no-fork index are still re-derived after the fact: the prover walks every account’s chain and recomputes each hash. And all of this is contention on a single node — concurrency at scale is out of scope.

Try it

Six racing spends against a wallet that only covers two — the funds gate commits what the balance covers and refuses the rest:

import { credits, spend, spendable, systemActor, topUp, userActor } from '@pwngh/economy-lab';

import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';

// Six spends race a wallet that only covers two — genuinely concurrent, one
// Promise.all. The funds gate is the only limiter: it commits what the balance covers
// and refuses the rest, so the balance can never go negative. The wallet is a fresh
// throwaway, so every run races the same thin balance.
export async function run(economy: Economy): Promise<SnippetReport> {
  const buyerId = `usr_thin_${crypto.randomUUID().slice(0, 6)}`;
  await economy.submit(
    topUp({
      idempotencyKey: `idem_${buyerId}`,
      actor: systemActor('docs'),
      userId: buyerId,
      amount: credits(300),
      source: 'card',
    }),
  );

  const attempts = await Promise.all(
    Array.from({ length: 6 }, (_, i) => {
      const orderId = `ord_${buyerId}_${i}`;
      return economy.submit(
        spend({
          idempotencyKey: orderId,
          actor: userActor(buyerId),
          orderId,
          buyerId,
          sku: 'Docs Drain Listing',
          price: credits(150), // each
          recipients: [{ sellerId: 'usr_nova', shareBps: 10_000 }],
        }),
      );
    }),
  );

  const committed = attempts.filter((o) => o.status === 'committed').length;
  const refused = attempts.filter(
    (o) => o.status === 'rejected' && o.detail.reason === 'INSUFFICIENT_FUNDS',
  ).length;
  const left = await economy.read.balance(spendable(buyerId));

  return {
    lines: [
      `attempts: ${attempts.length} — all in flight at once`,
      `committed: ${committed} · refused INSUFFICIENT_FUNDS: ${refused}`,
      `left in the wallet: ${left.minor / 100n} credits — never below zero`,
    ],
  };
}

The same burst, with its tally, runs on the console’s market page.

Recap

  • Contending operations serialize on per-account locks taken in one global order — the engine, not luck, prevents interleaving.
  • A refusal under contention is data: the funds gate rejects with INSUFFICIENT_FUNDS, and no account goes below zero.
  • The books stay provable under any interleaving; read.health() re-derives the invariants on demand.

Edit the drain block above to push on this: raise the number of racing spends, shrink the wallet, and watch the tally. Commits plus refusals always sum to the attempts, and the balance never goes negative, whatever the interleaving.

See also