Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds ➔
👥 Community & Social
YouTube Security VideosWelcome to GitHub Copilot Day: the future of agentic engineering(22.09.2026 um 20:00 Uhr)
•
YouTube Security VideosMicrosoft Mechanics: What Can a Copilot Agent Actually Read?(22.09.2026 um 20:27 Uhr)
•••
Unix & Linux ServerPeppermintOS Is Moving From Xorg to XLibre to Avoid Wayland(22.09.2026 um 19:58 Uhr)
•
Sicherheitslücken (CVE)USN-8803-1: Sudo vulnerability(22.09.2026 um 16:15 Uhr)
•
Sichere ProgrammierungClaude Opus 5.5 is now available in GitHub Copilot(22.09.2026 um 19:10 Uhr)
•
Sichere ProgrammierungColab is now part of your Google AI plan(22.09.2026 um 20:51 Uhr)
••
Sichere ProgrammierungThe Hidden Production Risks of Third-Party SDKs(22.09.2026 um 20:00 Uhr)
•
YouTube Security VideosWelcome to GitHub Copilot Day: the future of agentic engineering(22.09.2026 um 20:00 Uhr)
•
YouTube Security VideosMicrosoft Mechanics: What Can a Copilot Agent Actually Read?(22.09.2026 um 20:27 Uhr)
•••
Unix & Linux ServerPeppermintOS Is Moving From Xorg to XLibre to Avoid Wayland(22.09.2026 um 19:58 Uhr)
•
Sicherheitslücken (CVE)USN-8803-1: Sudo vulnerability(22.09.2026 um 16:15 Uhr)
•
Sichere ProgrammierungClaude Opus 5.5 is now available in GitHub Copilot(22.09.2026 um 19:10 Uhr)
•
Sichere ProgrammierungColab is now part of your Google AI plan(22.09.2026 um 20:51 Uhr)
••
Sichere ProgrammierungThe Hidden Production Risks of Third-Party SDKs(22.09.2026 um 20:00 Uhr)
•
Intelligence View
⚡ tsecurity.de Intelligence

The race condition a stress test found in my double-entry ledger — and how I fixed it

I'm building ledger-service, a double-entry e-wallet ledger in Java 21 / Spring Boot 3.5 / PostgreSQL. It's live on Render. Early on I wrote a stress test that fires 50 transfers at the same account at once and asserts the books are never…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!

I'm building ledger-service, a double-entry e-wallet ledger in Java 21 / Spring Boot 3.5 / PostgreSQL. It's live on Render. Early on I wrote a stress test that fires 50 transfers at the same account at once and asserts the books are never corrupted. It went red — and the way it went red is the most useful thing I've learned building this.



This post walks the whole chain: why a money ledger keeps a balance cache at all, the read-modify-write race that cache invites, how I detected it, the fix (optimistic locking + bounded retry), the benchmark that justified choosing optimistic over pessimistic locking, and how idempotency has to compose with retry so a network hiccup never double-spends.






The setup: a ledger, and why it has a cache



The source of truth is a double-entry, append-only table (ADR-0005). Every money operation writes at least one balanced DEBIT/CREDIT pair where Σ DEBIT == Σ CREDIT, and ledger_entries is insert-only — no UPDATE, no DELETE. A mistake is fixed by posting a correcting entry, never by editing history. This is the model Stripe, Modern Treasury, and Formance all use, and it's what gives you an audit trail you can trust.



But "what is account X's balance?" should not be a SUM over every entry that account has ever had. So I keep a cache: accounts.balance is a materialized Σ of that account's entries, updated in the same transaction as the entries themselves (ADR-0006). The entries are the truth; the balance is a derived read cache that stays O(1).



That cache is exactly where concurrency bites.






The race



Two requests debit the same account at the same time:




R1: read balance $500 (enough)      R2: read balance $500 (enough)
R1: commit −$300 → $200 R2: commit −$400 → −$200 ← overdraft / lost update






Both read $500, both decide they have enough, both write back their own idea of the new balance. One write silently clobbers the other: a lost update, and a balance that no longer matches the ledger entries underneath it.



The trap is assuming the database stops this for you. It does not. PostgreSQL's default isolation level, READ COMMITTED, only guarantees you don't read uncommitted data — it does nothing about two transactions that each read-then-write the same row concurrently. A read-modify-write race sails right through it.






Detecting it: the stress test



Here's the test that surfaced the bug. Fund one account, then fire N = 50 transfers out of it concurrently and check the books afterward:




AtomicInteger successes = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < N; i++) {
pool.submit(() -> {
start.await(); // line them all up...
int code = post("/transfers", from, to, AMOUNT); // ...then fire at once
if (code == 201) successes.incrementAndGet();
});
}
start.countDown();
// after all complete:
assertThat(balanceCache(from)).isEqualTo(ledgerBalance(from)); // cache == Σ entries
assertThat(balanceCache(from)).isGreaterThanOrEqualTo(0); // no overdraft
assertThat(balanceCache(to)).isEqualTo((long) ok * AMOUNT); // exact accounting






The assertions are deliberately timing-independent — they hold for any split of successes and failures, because they compare the cache against the ledger truth rather than against a fixed expected count. That's what makes the test a stable regression guard instead of a flaky one.



I confirmed the bug by experiment: with the @Version column removed, ~85% of the cache updates were lost and these assertions went red — the cached balance drifted far from the sum of the entries. The cache and the truth disagreed, which in a money system is the whole ballgame.






The fix, part 1: optimistic locking



accounts already had a version BIGINT column, because the Account is the aggregate / locking boundary (ADR-0010). Mapping it as a JPA @Version turns every balance write into a compare-and-set:




UPDATE accounts SET balance = ?, version = version + 1
WHERE id = ? AND version = ?






Two concurrent writers both load version 7. The first to commit sets it to 8. The second's UPDATE ... WHERE version = 7 now matches zero rows, and Hibernate raises OptimisticLockingFailureException at commit time. The lost update is now impossible: instead of silently clobbering, the loser is told it lost.



The key property: this is detection, not blocking. No reader ever waits for a lock. For a ledger — where balance/history reads vastly outnumber writes — that matters a lot.






The fix, part 2: bounded retry



Detection alone isn't enough. With @Version in place but no retry, the stress test stopped corrupting data but a big chunk of transfers now failed with a conflict — correct, but a lousy experience. So the loser needs to retry.



The retry helper sits outside @Transactional, and that placement is the whole point:




public <T> T execute(Supplier<T> operation) {
for (int attempt = 1; ; attempt++) {
try {
return operation.get(); // a FRESH transaction each attempt
} catch (OptimisticLockingFailureException e) {
if (attempt >= maxAttempts) throw new ConcurrencyConflictException();
sleep(backoffWithFullJitter(attempt)); // 25–200 ms, capped
}
}
}






Each attempt is a brand-new transaction that reloads the row at its current version — retrying inside the failed transaction would just re-fail against the stale version. Defaults: 5 attempts, exponential backoff with full jitter (so a thundering herd doesn't resynchronize into another collision), and on exhaustion a clean 409 Conflict — never a 500.



There's a small piece of reasoning that makes this provably terminating under moderate load: the k-th committer can only lose to a distinct earlier committer, so it needs at most k attempts. With 4 concurrent writers and a 5-attempt budget, all 4 succeed deterministically — no flaky test. A genuine hot account (more concurrent writers than the attempt budget) surfaces as 409, which is honest backpressure rather than a hidden corruption.






Optimistic vs pessimistic: the measured choice



The obvious alternative is pessimistic locking — SELECT ... FOR UPDATE to lock the row before touching it, so writer #2 simply waits. No retries, easy to reason about. So why optimistic?



I didn't want to argue this from vibes, so I wrote a benchmark (TransferConcurrencyBenchmark) that runs the identical transfer logic under both strategies, 50 concurrent writers, against one real PostgreSQL:























Scenario Optimistic + retry Pessimistic FOR UPDATE

Low-contention (50 disjoint account pairs)

34 ms · 50/50 ok · 0 retry waste
31 ms · 50/50 ok

High-contention (50 transfers → 1 hot row)
731 ms · 50/50 ok · 185 retry waste

358 ms · 50/50 ok


Reading the numbers:





  • Low contention is the common case, and it's a tie (34 vs 31 ms) — but optimistic wastes zero retries and, crucially, never blocks reads. That's the deciding factor for a read-heavy ledger.


  • On a single hot row, pessimistic is ~2× faster (358 vs 731 ms) and wastes nothing, while optimistic burns 185 extra attempts (≈4.7× the work) on collisions and backoff. But pessimistic "wins" here precisely by serializing and blocking reads — the thing I'm trying to avoid — and it doesn't actually solve a hot account, it just queues it.



So the verdict is optimistic + retry, and the value of the benchmark isn't "optimistic is faster" (it isn't, under contention) — it's that those 185 wasted retries quantify the threshold at which a truly hot account (think: every top-up debiting a shared SYSTEM_FUNDING row) needs a real escalation: async queueing or sub-account sharding, not flipping the whole system to pessimistic locks.






Composing with idempotency



There's one more way to double-spend that retry actually makes worse if you're not careful. A client whose connection drops after the server committed will retry the whole HTTP request — and now you risk posting the transfer twice. Retry-on-conflict and retry-on-network-blip are different problems, and the fix for one must not break the other.



So both money endpoints require an Idempotency-Key header (ADR-0012, the Stripe pattern). The mechanism that makes it concurrency-safe is claim-first:




INSERT INTO idempotency_keys (key, status) VALUES (?, 'PENDING')
ON CONFLICT (key) DO NOTHING; -- committed immediately, before business logic






That atomic insert is the serialization point. Whoever wins the claim runs the operation; a concurrent request with the same key sees the committed PENDING row and gets 409 (in-flight) instead of running a second time. A completed key replays the stored response; a key reused with a different body gets 422 (a client contract violation, deliberately distinct from the 409 conflict code).



The reason this composes cleanly with the retry from earlier: the optimistic-lock retry sits after the key is claimed. All those internal attempts happen under one already-claimed idempotency key, so they're completely invisible to the client and can never produce a second posting. Conflict-retry and request-idempotency stack instead of fighting.






What I'd reach for next



The cache is fast but it can drift (a bug, a partial failure). So a scheduled reconciliation job re-derives every balance from the immutable entries and alerts on any mismatch — it never auto-corrects; an operator posts a correcting entry. The append-only ledger means the truth is always recoverable.



And the hot-account ceiling those 185 retries exposed is the next real scaling problem: when one row is genuinely contended, the answer is async posting or sharding that account, with the retry rate as the signal that tells you when you've crossed the line.






The throughline: in a money system, the cache disagreeing with the ledger is the failure that matters, and a default-isolation database won't stop you from creating it. A @Version compare-and-set makes the lost update impossible, bounded retry with jitter makes it invisible under normal load, a benchmark tells you the price you're paying and where the ceiling is, and idempotency makes sure the retries — at every layer — never turn into double-spends.



Code: github.com/xidoke/ledger-service — the concurrency model doc and ADRs 0005, 0006, 0011, 0012 go deeper. Live demo: ledger-service-bjzr.onrender.com (free instance — first request cold-starts ~50 s).

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten The race condition a stress test found in my double-entry ledger — and how I fixed it

Thematisch verwandte Begriffe: race, condition, stress, test · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-75517 | Novu provides an API for sending notifications through multiple channels…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel • Rechts: nächster Artikel • unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger • Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick