Skip to content

bug: make deposit/withdraw/transfer transactional with row locks (sfind-baf6d2f9) - #376

Open
devin-ai-integration[bot] wants to merge 3 commits into
DevOpsfrom
devin/sfind-baf6d2f9-atomic-balance-updates
Open

devin-ai-integration[bot] wants to merge 3 commits into
DevOpsfrom
devin/sfind-baf6d2f9-atomic-balance-updates

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Summary

Fixes code-scan finding sfind-baf6d2f929354c71b4c749980422fad2 (high): deposit, withdraw and transferAmount in AccountService did read-check-write on balances with no transaction or row lock, so overlapping requests (2 replicas in kubernetes/bankapp-deployment.yml) could overdraw an account, drop another user's incoming credit, or leave a transfer half-applied.

Each method is now one @Transactional unit that re-reads the account row under a database lock before checking or changing the balance:

private Account lockAccount(Long id) {
    Account account = entityManager.find(Account.class, id);
    entityManager.refresh(account, LockModeType.PESSIMISTIC_WRITE); // SELECT ... FOR UPDATE
    return account;
}
  • transferAmount locks both rows in ascending id order (no deadlock between A->B and B->A), then checks funds on the locked sender. Both balance updates and both Transaction records commit or roll back together.
  • refresh(..., PESSIMISTIC_WRITE) instead of a @Lock repository query: spring.jpa.open-in-view is on by default, so the Account the controller loaded stays managed for the whole request. A locking query would return that cached copy with the balance read before the lock. A first version of this branch did exactly that, and the new HTTP test caught it (fixed in 8b9ffb4).
  • Pessimistic locking rather than @Version: no schema change needed (ddl-auto=update on existing MySQL rows). Method signatures are unchanged.

Tests

  • AccountServiceConcurrencyTest covers 20 concurrent full-balance withdrawals (exactly 1 succeeds, final balance 0), 10 concurrent transfers of the whole balance (1 succeeds), Bob's 1000 credit racing 20 x 0.01 credits to Alice (nothing lost), 20 concurrent deposits, opposite-direction transfers, and rollback when the "Transfer Out to " + name label overflows the 255-char type column.
  • BankControllerConcurrencyTest sends concurrent POST /withdraw and /transfer over real HTTP (RANDOM_PORT, form login), which exercises open-in-view.
  • application-test.properties: H2 LOCK_TIMEOUT=30000 (H2's default of 1s would time out contended threads).

Run locally (Maven Central isn't reachable from the build box, so the JUnit Platform launcher ran directly against the Maven test classpath):

H2 (test profile) MySQL 5.7
DevOps service 0/6, controller 0/2 service 0/6
this branch service 6/6, controller 2/2, contextLoads 1/1 service 6/6, controller 2/2

HTTP replay against the running app on MySQL (Mallory has 100 and sends 10 concurrent POST /transfer amount=100 to 10 accomplices):

  • DevOps: Mallory ends at 0.00, accomplices hold 900.00 in total
  • this branch: Mallory ends at 0.00, accomplices hold 100.00 in total

Not covered: ./mvnw test via surefire (the surefire-junit-platform provider isn't cached and Maven Central is blocked; access has been requested). The MySQL runs used 5.7 because the mysql:8.0 image can't be pulled here.

Overlap

Open PRs #307, #312 and #286 (atomic transfer part) address the same race. This PR exists because a new finding was filed, and only one of them should land. Unlike a plain @Lock repository query, this PR also handles the open-in-view stale-entity case and covers deposit/withdraw.

Not in scope, but related: non-positive amounts are still accepted (#290), and CSRF is disabled in SecurityConfig.

Devin-Org: engineering

Link to Devin session: https://devin-gtm.devinenterprise.com/sessions/5f067f8fb1924711b7e1e5e6b849bf09
Open in Devin Desktop: https://devin-gtm.devinenterprise.com/desktop/session/5f067f8fb1924711b7e1e5e6b849bf09?variant=devin


Devin Review

Status Commit
⚪ Not started —

Run Devin Review

Enable auto-review to review your pull requests automatically.

Devin Review (Staging)


Devin Review

deposit, withdraw and transferAmount now run in one @transactional unit
and re-read the account rows with SELECT ... FOR UPDATE (PESSIMISTIC_WRITE)
before checking/changing the balance. Transfers lock both rows in
ascending id order to avoid deadlocks, and all balance + transaction
record writes commit or roll back together.

Adds AccountServiceConcurrencyTest (20 concurrent full-balance
withdrawals -> exactly one succeeds, double-spend via transfers,
lost credit, concurrent deposits, opposite-direction transfers,
rollback on failed transfer).

Refs: sfind-baf6d2f929354c71b4c749980422fad2
With spring.jpa.open-in-view the controller's Account stays managed for
the whole request, so a locking query returned that cached instance
with the balance read before the lock. lockAccount now refreshes the
entity with PESSIMISTIC_WRITE, which issues SELECT ... FOR UPDATE and
overwrites the in-memory state.

Adds BankControllerConcurrencyTest, which fires concurrent POST
/withdraw and /transfer requests over real HTTP.

Refs: sfind-baf6d2f929354c71b4c749980422fad2
@devin-ai-integration

Copy link
Copy Markdown
Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration

Copy link
Copy Markdown
Author

Runtime-tested b9821ca locally in the browser against real MySQL 5.7.

Concurrent transfer and rollback evidence
  • Ten parallel transfer requests from Alice's 50.00 balance: 1 accepted, 9 insufficient funds.
  • Final balances: Alice 0.00, Bob 70.00, with exactly one new 50.00 debit/credit pair. The database rows match the histories.
  • A transfer of 40 to a 250-character recipient forced a rollback. The dashboard showed 50.00 both immediately and after reload, and no 40.00 transaction rows were written.
Alice final history Bob final history
Alice debit history Bob credit history
Rollback error Fresh dashboard
Balance after rollback error Balance after reload

Recorded browser run

Other flows and coverage limits

Deposit 100, withdraw 30, rejected withdraw of 1000, transfer 20, recipient history, and the nonexistent-recipient error all showed the expected balances.

The Bootstrap CDN was unreachable, so the forms were submitted unstyled. The base-branch comparison and multi-replica scenarios were not tested in the browser.

Written by Devin

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants