Repository navigation
Fix accounting race conditions and run the addclass concurrency test - #6133
willgearty wants to merge 4 commits into
Conversation
The donation LineItemType was created lazily with get_or_create(). There is no unique constraint on (program, text), so two requests that both found no row could each create one, after which every get_or_create() raised MultipleObjectsReturned. DonationModule.students() did this on a read path that user_can_join() reaches, so both racing requests, and any later ones, returned 500. - Add LineItemType.get_or_create_by_text(), which returns the oldest matching row and, when there is none, locks the program row before re-checking and creating, so concurrent callers create one row. It uses SELECT ... FOR NO KEY UPDATE so inserts that reference the program are not blocked. - Use it for every donation lookup: both modules' line_item_type(), Stripe's check_setup() and payonline() (which used a strict .get()), and ProgramAccountingController.donation_lineitemtype(). - DonationModule.students() now filters by program and text instead of calling line_item_type(), so it never writes and also counts donors on duplicate rows left by earlier races. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PKPRE8AGqavuf4cvm4v2o
The skip guard checked connection.features.supports_select_for_update, which Django does not define, so getattr() always returned False and the test was skipped on every backend, Postgres included. Use Django's has_select_for_update flag directly so a wrong name raises instead of silently skipping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PKPRE8AGqavuf4cvm4v2o
The remaining lazy LineItemType.objects.get_or_create() calls had the same race as the donation item: setup_lineitemtypes() (admission, payments, financial aid, optional and select items), default_refund_lineitemtype(), and the sibling discount setter on Program. The refund one matters most: IndividualAccountingController.amount_refunded() calls it on every use, including the Stripe payment page, so concurrent students could create duplicate refund rows and every later lookup would raise MultipleObjectsReturned. Generalize get_or_create_by_text() into get_or_create_for_program(), which takes the same lookup kwargs and defaults as get_or_create() and returns (obj, created), so each call site keeps its exact lookup. When duplicates exist it now returns the newest, matching the existing default_*_lineitemtype() readers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PKPRE8AGqavuf4cvm4v2o
Several accounting paths checked for a row and then created it, which lets concurrent requests both create one. The ones that move money: - ensure_required_transfers() runs on every amount_due()/amount_requested() call, so two concurrent first requests from a student could each create the admission charge, billing them twice. - apply_preferences()/set_preference() delete a user's matching transfers and recreate them. Being atomic does not help under READ COMMITTED: each delete misses the other's uncommitted inserts, so a double-submitted extra-costs or donation form kept both sets of charges. - FinancialAidGrant.finalize() and SplashInfo.execute_sibling_discount() could each create a second aid or discount transfer. Lock the user's row (FOR NO KEY UPDATE) before re-checking and creating in each of these, and in set_finaid_params(). Program-scoped creations (the program Account and select-item LineItemOptions) lock the program row. Move the locking into esp/db/locking.py as lock_row() and the model-agnostic get_or_create_locked(), which LineItemType.get_or_create_for_program() now delegates to. lock_row() resolves the model via _meta so it works with request.user's SimpleLazyObject. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019PKPRE8AGqavuf4cvm4v2o
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to
|
Description
AddClassConcurrencyRegressionTesthas never run, on any database. Once enabled, it uncovered a race in how accounting rows are lazily created. Auditing that pattern across the accounting code turned up several more races, some of which charge students twice or grant aid twice. This PR fixes them and turns the test on, with no test-side workaround.1. The concurrency test was always skipped
The skip guard checked
connection.features.supports_select_for_update. Django has no such flag; the real one ishas_select_for_update. Because the check usedgetattr(..., False), it always returned False, so the test skipped on Postgres too, with a message blaming SQLite. It now readshas_select_for_updatedirectly, so a wrong name would raise instead of silently skipping.2. The race classes
Lazy
get_or_create()without a unique constraint. Two requests that both find no row each create one. From then on everyget_or_create()for it raisesMultipleObjectsReturned, so the page keeps returning 500 until someone deletes the duplicate by hand. This is what the concurrency test hit:DonationModule.students()created the donation line item on a read path:Program.user_can_join()→_students_in_program(), run whenprogram_size_maxis set and the student isn't already in the program.Hand-written check-then-create. The same race, but the result is duplicate charges rather than errors.
@transaction.atomicdoesn't prevent it under Postgres' default READ COMMITTED isolation, because a concurrent transaction's uncommitted inserts are invisible to each statement.3. Every site fixed
IndividualAccountingController.ensure_required_transfers()(runs on everyamount_due()/amount_requested())apply_preferences()/set_preference()FinancialAidGrant.finalize()finalizedfrom the databaseSplashInfo.execute_sibling_discount()MultipleObjectsReturnedset_finaid_params()MultipleObjectsReturnedLineItemType(both modules'line_item_type(), Stripecheck_setup()/payonline(),donation_lineitemtype())MultipleObjectsReturned→ 500 on registrationLineItemType(default_refund_lineitemtype(), called byamount_refunded()on the Stripe payment page)MultipleObjectsReturnedsetup_lineitemtypes()items and options, sibling-discountLineItemType, programAccountMultipleObjectsReturnedThe mechanism (
esp/db/locking.py)lock_row(instance)takesSELECT ... FOR NO KEY UPDATEon the user or program row. That serializes callers that lock the same row, without blocking ordinary inserts that merely reference it._meta, becauserequest.useris aSimpleLazyObject. The existing suite caughttype()getting this wrong.get_or_create_locked(model, lock_on, defaults=None, **kwargs)has the same arguments and(obj, created)return asget_or_create(), so every call site keeps its exact lookup.default_*_lineitemtype()readers (which order by-id), instead of raising.create(), so it doesn't supportfield__lookupkeys. No caller uses them.LineItemType.get_or_create_for_program()delegates to it.ensure_required_transfers()takes the lock only when a transfer is missing, so the common path stays lock-free.DonationModule.students()no longer writes. It filters ontransfer__line_item__programandtransfer__line_item__text, which also counts donors on duplicate rows.One deliberate behavior change: Stripe's
check_setup()used to look up withrequired=False. If an admin had marked the donation item required, it would silently create a second row; now it reuses the existing one.Alternative considered: unique constraints. Lookups like "Program admission" deliberately include
amount_dec, so the same text can legitimately repeat. Constraints would also need migrations that merge existing duplicates and repointTransfer/LineItemOptionsrows. Row locks fix the races without a schema change.Reviewed and deliberately left alone
charge_payment: a double submit can't double-charge. Stripe tokens are single-use, so the secondCharge.createfails withInvalidRequestError(the code already expects this), and itstransaction.atomic()rolls back the second payment record.GlobalAccountingController.setup_accounts()(the global receivable/payable/grants accounts): runs only frominstall()onpost_migrate, so it can't race in normal operation.SplashInfo.getForUser()(check-then-create): not accounting, and its readers already tolerate duplicates (q[0]).Other things found (not changed)
ATOMIC_REQUESTS = Truesits at the top level ofdjango_settings.py. Django only honors it inside aDATABASESentry, so in the repo's settings requests run in autocommit, and these locks are held only for their shortatomic()blocks. If productionlocal_settingsenables it properly, locks are held until the end of the request. That's still correct, but requests that create rows for the same user or program would queue behind each other.DonationModule's POST handler callsiac.set_preference('Donation to Learning Unlimited', ...)with a hard-coded text. If a program customizesdonation_text, that lookup won't find the line item.Related Issue
None. This came out of an audit of the frontend tests (learning-unlimited/ESP-Website#6130).
Type of Change
Testing
All runs used CI's tool versions (
pytest==7.4.4,pytest-django==4.5.2,pytest-xdist==3.3.1,pytest-split==0.11.0) on Postgres 16.Concurrency regression tests (
accounting/tests/test_concurrency.py)Each test races two threads, with
save()slowed down to widen the race window. Each was checked by removing its fix:test_line_item_type_created_oncetest_admission_charged_oncetest_double_submitted_preference_kept_oncetest_financial_aid_granted_oncefinalize()fails 3 of 3 (2 aid transfers)Unit tests
LineItemTypeGetOrCreateForProgramTest: creates when missing, reuses an existing row, newest of duplicates, ignores other programs, matches on every lookup field, appliesdefaultsonly on create, refund lookup with duplicates doesn't raise.DonationModuleTest:students()creates no line item and counts donors across duplicates.The addclass concurrency test
Suites
Accounting, donation, Stripe, credit-card, financial-aid, extra-costs, student registration and program tests: 292 passed, on fresh databases.
Full suite: ran
deploy/ci/setup_test_db(fresh per-worker databases, as in CI), thendeploy/ci/script.mainin this container (no LaTeX/ghostscript, which CI installs). No new failures.deploy/lintpasses.Existing tests pass
New tests added (if applicable)
Manually verified
AI Disclosure
Written by Claude Code (an AI assistant) at the request of @willgearty, including the investigation and local verification above.
Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_019PKPRE8AGqavuf4cvm4v2o
Summary by CodeRabbit
Bug Fixes
Tests