fix(budgets): create the settings row once when two requests race - #417
aivong-openhands wants to merge 5 commits into
Conversation
_get_or_create_settings reads the settings row and, finding none, inserts one with no lock and no ON CONFLICT, while OrgBudgetSettings.org_id is unique. Two requests that both read before either inserted -- the maintenance job reaching an org for the first time while an admin opens its budgets page -- both insert, and the loser dies on the unique constraint instead of using the row the winner just created. Insert inside a savepoint and, on IntegrityError, re-read the row the winner committed. Losing the race now costs a re-read rather than a failed request, and the savepoint keeps the error from poisoning the caller's transaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||
aivong-openhands
left a comment
There was a problem hiding this comment.
🟡 Taste Rating: Acceptable
Real race, correctly diagnosed, and the savepoint is the right mechanism — without begin_nested the IntegrityError poisons the outer transaction and the re-read cannot run at all. The "maintenance job reaches an org for the first time while an admin opens its budgets page" pairing is a concrete, plausible trigger rather than a hypothetical, which is the bar I want for concurrency work.
Two blocking items and a design question.
[CRITICAL ISSUES]
-
[CI] Lint is red.
ruff formatfails on this branch. I reproduced it locally — the offenders are all in the new test: theBudgetFinancialSnapshotResult(...)assignment, twopatch.object(...)calls, and the trailing.scalars().all()chain all exceed the line budget or need re-wrapping.ruff format tests/unit/test_org_budget_service.pyfixes it. Mechanical, but it is a red required check. -
[
server/services/org_budget_service.py:831] Theexcept IntegrityErroris unconditional.create_settingsinserts the settings row and threeDEFAULT_THRESHOLDSrows, and the settings row carries an FK toorg.id. So anIntegrityErrorhere can mean "someone beat me to it" or it can mean "this org_id does not exist." Today the second case is handled by accident —get_settingsreturnsNoneand you re-raise — but the re-raised exception is theIntegrityErrorfrom inside the savepoint, surfacing as a 500 with a constraint name. That is acceptable behaviour, and theif settings is None: raiseguard is genuinely good defensive design. Worth narrowing the catch to the unique violation (exc.orig.sqlstate == '23505', or matching the constraint name) so the two cases stay distinguishable to whoever reads the logs. Non-blocking if you disagree, but please make the choice deliberately rather than leaving it implicit.
[IMPROVEMENT OPPORTUNITIES]
-
[
server/services/org_budget_service.py:819-836] Is there a simpler way? This codebase already solves exactly this problem one file over:OrgBudgetStore.record_cycle_baselinesusesinsert(...).on_conflict_do_update(constraint=...)and documents "first writer wins, which is what concurrent reconcilers need." Here you have introduced a second, different idiom for the same concern.ON CONFLICT DO NOTHINGon the settings insert followed by an unconditional read would be flatter — no savepoint, no exception path, no re-read branch — and consistent with the store's existing pattern. The complication is the three threshold rows, which also need conflict handling, so it is not a one-liner. I am not asking you to change it; I am asking you to say in the PR description why try/savepoint/except beatON CONFLICThere, because the next person will otherwise wonder which idiom this repo actually uses. -
[
server/services/org_budget_service.py:831-835] Isolation-level assumption is load-bearing and unstated. The recovery re-read only finds the winner's row because Postgres defaults to READ COMMITTED, where each statement sees a fresh snapshot. Under REPEATABLE READ the loser's snapshot predates the winner's commit,get_settingsreturnsNone, and you re-raise — the bug comes straight back. That is fine today, but it is invisible in the code and would fail silently if anyone ever set a non-default isolation level on this session. One line of comment stating the dependency is warranted; this is precisely the "subtle requirement the reader cannot infer" case that deserves a comment, unlike most of the comments in this PR set. -
[
server/services/org_budget_service.py:819-822] Unnecessary comments: the four-line block re-narrates the PR description. The mechanically useful half is "insert inside a savepoint so the IntegrityError does not poison the caller's transaction" — the rest ("the maintenance job reaching an org for the first time while an admin opens its budgets page") is motivation that belongs in the commit message. Trade those lines for the isolation-level note above and the comment budget comes out even.
[TESTING GAPS]
-
[
tests/unit/test_org_budget_service.py:2628+] The race is simulated, not raced._second_request_readreturnsNoneon first call via a patchedstore.get_settings, so what the test proves is "given a stale read, the insert path recovers" — not that two genuinely concurrent sessions interleave that way. I think that is the right trade: a real race would be non-deterministic and flaky in CI, and the patch is narrow (one method, first call only) with the realget_settingsrestored immediately after. It does drive two real sessions against real Postgres and asserts on committed state (len(rows) == 1), which is the part that matters, and it fails without the fix. Calling it out so the limitation is on the record, not asking for a change. -
The test asserts one settings row exists but never asserts the threshold rows are not duplicated. If the savepoint rollback failed to discard the loser's three
DEFAULT_THRESHOLDSinserts you would get six threshold rows and this test would still pass. That is the assertion I would most want to add — it is the part of the savepoint behaviour that is actually non-obvious.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
Concurrency and transaction-boundary changes on a path that every budget entry point funnels through (get_budget_state, update_budget_settings, upsert_user_override, delete_user_override, run_budget_maintenance all call _get_or_create_settings). The happy path is unchanged — the savepoint only wraps an insert that previously ran bare — so the realistic downside is confined to the recovery branch. But savepoint semantics interact with the request-scoped session in DbSessionInjector, and a mistake there is the kind that shows up as an intermittent 500 under load rather than a test failure. Python tests pass; Lint python is red.
Evidence is a pytest invocation. For a concurrency fix I would normally want more than that, and here the test is a simulation rather than a true race — so the evidence genuinely is weaker than usual. The committed-state assertion against real Postgres carries most of the weight. Adding the threshold-count assertion would materially strengthen it.
VERDICT:
❌ Needs rework, but only just — fix the ruff format failure and make a deliberate call on the unconditional except IntegrityError. The core approach is sound and I would approve it with those addressed.
KEY INSIGHT:
The savepoint is the right tool, but this repo already had an answer for concurrent first-writer inserts one file away; introducing a second idiom for the same problem needs a stated reason.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was generated by an AI agent (OpenHands) on behalf of @aivong-openhands.
The recovery path caught every IntegrityError, so a settings insert that failed because the org row does not exist was indistinguishable in the logs from losing the create race. Match on SQLSTATE 23505 and re-raise anything else, so an FK violation surfaces as itself. Trade the comment narrating the race for the two facts a reader cannot infer: why the savepoint is there, and that the recovery re-read depends on READ COMMITTED. Assert the threshold rows are not duplicated, which is the part of the savepoint rollback that is actually non-obvious, and run ruff format over the new test. Co-authored-by: openhands <openhands@all-hands.dev>
|
Thanks — addressed in
Narrowed the One thing worth recording, because it cuts against the obvious test for this: the FK case is behaviourally identical with or without the guard. With no org row the recovery re-read also finds nothing, so the Comment budget traded as suggested. Dropped the four lines re-narrating the race (that motivation lives in the commit message, where you're right that it belongs) and spent them on the two facts a reader genuinely cannot infer: why the savepoint is there at all, and that the recovery re-read depends on READ COMMITTED taking a fresh per-statement snapshot. Under REPEATABLE READ the loser's snapshot predates the winner's commit, the re-read returns Added the threshold-count assertion. It's in, and I verified it actually bites (planting three extra threshold rows makes the test fail). But it is weaker than it reads, and you should know why before treating it as evidence:
So On the simulated-vs-real race — agreed with your read, and thanks for putting the limitation on the record rather than asking me to chase a flaky true race. This comment was written by an AI agent (OpenHands) on behalf of @aivong-openhands. |
Mutation review of the tests on this branchI hand-wrote 9 mutants against Controls — the suite does its job
What makes these die is that the test drives two real sessions against real Postgres and asserts on committed state after both are closed, rather than on a mock's call log. C2 is the one I'd single out: it dies on Survivors
M1 — the recovery path can skip baseline hydration unnoticedThe read path calls @pytest.mark.asyncio
async def test_race_recovery_hydrates_the_winners_cycle_baselines(
async_session_maker, budget_org
):
cycle_start_at = _current_cycle_start(datetime.now(UTC), 1)
async with async_session_maker() as winner:
winner.add(
OrgBudgetSettings(
org_id=budget_org.id,
enabled=False,
reset_day=1,
cycle_start_at=cycle_start_at,
cycle_start_spend=0.0,
user_cycle_start_spend={},
)
)
winner.add(
OrgBudgetCycleBaseline(
org_id=budget_org.id,
user_id='member',
cycle_start_at=cycle_start_at,
baseline_spend=7.0,
source=OrgBudgetCycleBaseline.SOURCE_LIVE_ROLLOVER,
observed_at=datetime.now(UTC),
)
)
await winner.commit()
async with async_session_maker() as loser:
loser_service = OrgBudgetService(loser)
real_get_settings = loser_service.store.get_settings
already_read = []
async def _stale_then_real(org_id):
if not already_read:
already_read.append(org_id)
return None
return await real_get_settings(org_id)
with patch.object(
loser_service.store, 'get_settings', AsyncMock(side_effect=_stale_then_real)
):
settings = await loser_service._get_or_create_settings(budget_org.id)
assert settings.user_cycle_start_spend == {'member': 7.0}Verified: passes on the branch unmodified, fails with M1 applied. M3 — nothing pins the
|
… recovery A mutation review of the suite on this branch found three surviving mutants, all in the recovery path added by the fix: - Deleting _hydrate_cycle_baselines from the recovery branch left the suite green, so the loser could be handed a settings row whose baselines were never reconciled -- a wrong number rather than the 500 this PR fixes. - Deleting the `settings is None` re-raise left it green, so the re-read that finds nothing would return None from a `-> OrgBudgetSettings` signature and fail as an AttributeError frames away from the cause. - Deleting the SQLSTATE guard left it green, so the narrowing added in 0c1af84 was unpinned. The natural test for the SQLSTATE guard -- a missing org, FK violation 23503 -- does not distinguish the two versions: with no org row the recovery re-read also finds nothing, so the `settings is None` guard re-raises either way. What does distinguish them is a non-unique IntegrityError raised where the re-read *does* find a row, which is why that error is synthesised; the real get_settings still drives the recovery read against Postgres. Also extract the stale-first-read stub the race tests share, and correct the threshold-count comment: create_settings flushes the settings row before adding any threshold row, so the loser's insert already fails before writing one. The assertion is regression cover for that ordering, not evidence about savepoint rollback. Co-authored-by: openhands <openhands@all-hands.dev>
|
Addressed in 873d05d. All three surviving mutants that pointed at a real gap are now killed, each by the test written for it — verified by re-applying M1, M2 and M3 to the service and confirming exactly one test fails per mutant:
Suite is now 44 passed, 7 skipped, and the controls (C1, C2, M4) still die — I re-ran those too, since I refactored the existing race test. The M1 test turns out to catch C1/C2/M4 independently as well, which is a useful side effect: the recovery path is now pinned from two directions rather than one. Two notes on the specifics. On M2 and the synthesised error. Your reasoning for why the FK case does not work is right, and I checked it rather than taking it on faith: with no org row the recovery re-read also comes back empty, so the On M6. Agreed on both halves — the assertion is worth keeping, and the comment claimed more than it proved. I reworded it to say what is actually true: I also pulled the stale-first-read stub out into a shared The This comment was created by an AI agent (OpenHands) on behalf of the user. |
aivong-openhands
left a comment
There was a problem hiding this comment.
🟡 Taste Rating: Acceptable
The fix is the right shape. A savepoint around the insert plus a re-read on unique violation is the textbook get-or-create recovery, and it matches what JiraIntegrationStore.get_or_create_active_email_link already does in this repo — catch, roll back, re-read, re-raise when the re-read comes back empty. The "Why a savepoint rather than ON CONFLICT" section is a genuinely good piece of reasoning: create_settings is a four-row unit of work with no unique constraint on org_budget_threshold(org_id, percentage) to conflict against, so ON CONFLICT DO NOTHING on the settings insert alone would leave the loser's three threshold rows stacked on the winner's. The transaction boundary gets all-or-nothing for free. Correct call.
One detail worth naming because it is load-bearing and invisible: savepoint rollback expunges the objects create_settings added to the session, so the caller's later commit() does not retry the insert and crash again. If SQLAlchemy did not do that, this fix would be broken. test_settings_row_is_created_once_when_two_requests_race commits the loser's session after recovery, so that behaviour is pinned rather than assumed. Good.
The reproduction test is the real thing, not mock theatre: two sessions on the same Postgres database, the loser's first read stubbed to miss while its recovery read runs against the live row. That is the correct way to make a race deterministic.
Where it falls short of 🟢
Two things, neither blocking.
The SQLSTATE read duplicates an existing repo helper, in a weaker form. server/app_lifespan/saas_app_lifespan_service.py already has _sqlstate(), which checks sqlstate and pgcode precisely because drivers disagree about which one they set. This PR open-codes a single-attribute getattr. On the async path this is harmless — SQLAlchemy's asyncpg adapter sets both attributes to the same value — so nothing is broken today. But there are now two answers in the tree to "what SQLSTATE did this error carry", and the newer one is the less careful of the pair. A consistency cost with no upside.
The test comments have outgrown the tests. The new block is 219 lines carrying 28 comment lines, against 58 in the 2,620 lines before it — roughly a 6x jump in density. The comments carrying the why of the race are worth having. The ones narrating what an assertion cannot currently catch, or apologising for a synthesised exception, are the PR description leaking into the source, where nothing keeps them true.
On the mutation-review tests
The three tests added in 873d05d are not equal. The hydration one earns its place — skipping _hydrate_cycle_baselines on the recovery path hands the caller a stale baseline and the request succeeds with a wrong number, which is silent corruption and exactly the kind of thing worth pinning. The empty-re-read one is fine too; it pins a real contract on a declared -> OrgBudgetSettings.
The non-unique-IntegrityError test is the weak one, and its own comment says why: "no reachable state of this code path raises a non-unique violation". It fabricates an exception, hand-sets an attribute on it, and asserts the attribute comes back. That tests the getattr call, not a behaviour the system can exhibit. It exists because a mutant survived — but a surviving mutant on an unreachable branch is a signal that the branch may be speculative, not that a test is missing. Worth asking whether the guard carries its weight before writing a test to protect it.
[IMPROVEMENT OPPORTUNITIES]
server/services/org_budget_service.py:836— reuse the existing_sqlstatehelper rather than open-coding a weaker single-attribute read.tests/unit/test_org_budget_service.py:2723-2727— four comment lines explaining that an assertion cannot fail today.tests/unit/test_org_budget_service.py:2807-2813— seven comment lines justifying a test of an unreachable branch.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟡 MEDIUM
Not because the change is large — ~20 lines on one method — but because of where it sits. _get_or_create_settings has seven callers, including the budgets page read path and the maintenance CronJob, and this alters its transaction structure for all of them. Mitigating factors are strong: the savepoint only wraps a path that previously raised, the recovery branch is unreachable unless an insert actually collides, and full CI is green including Python Tests on Linux (3.12).
The residual risk is the isolation-level dependency. Under REPEATABLE READ the re-read returns None and the original IntegrityError re-raises — it degrades to today's behaviour rather than to something worse, which is the right failure mode. READ COMMITTED is the Postgres default and this session sets no isolation level, so the dependency holds; it is correctly called out in a comment, since it is invisible in the code. Worth remembering if anyone ever sets a session-level isolation on this path.
A human has tested these changes is unchecked and the PR is a draft, which is consistent — the concurrent-insert path is hard to exercise by hand and the two-session Postgres test is a reasonable substitute. Flagging it only so it is a deliberate decision at merge time rather than an oversight.
VERDICT: ✅ Worth merging — the fix is correct and the reproduction test is honest. Both notes above are cleanups, not corrections.
KEY INSIGHT: The savepoint-over-ON CONFLICT decision is right for the reason given, and the reason generalises: ON CONFLICT is an idiom for single-row upserts, and create_settings is a four-row unit of work with no constraint for the threshold rows to conflict against. What the PR gets less right is the surrounding accretion — a second SQLSTATE helper where one already existed, and a test whose own comment concedes it guards an unreachable branch.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was created by an AI agent (OpenHands) on behalf of @aivong-openhands.
Address PR review feedback: - Extract _sqlstate into storage.database.sqlstate and reuse it in both org_budget_service and saas_app_lifespan_service, so there is one answer to which SQLSTATE an error carries (checks sqlstate and pgcode). - Trim the over-grown comments on the settings-race tests. Co-authored-by: openhands <openhands@all-hands.dev>
|
Addressed the review feedback in a090834. 1. Reuse the SQLSTATE helper instead of open-coding a weaker read 2. Trimmed the over-grown test comments
3. On the "unreachable branch" test Verification
All three inline review threads have been replied to and resolved. This comment was created by an AI agent (OpenHands) on behalf of @aivong-openhands. |
HUMAN:
AGENT:
Why
Two requests arriving together at an organization with no budget settings row both tried to create one, and the loser failed with an
IntegrityErrorinstead of using the row the winner had just committed._get_or_create_settingsreads, finds nothing, and inserts — with no lock and noON CONFLICT— whileOrgBudgetSettings.org_idis unique. The concrete pairing is the maintenance job covering an org for the first time while an admin opens that org's budgets page: a plain read of the budgets page fails on a unique-constraint violation.The insert now happens inside a savepoint, and a unique violation is handled by re-reading the row the winner committed. Losing the race costs a re-read rather than a failed request, and the savepoint keeps the error from poisoning the caller's transaction.
Summary
23505and re-raise anything else, so an insert that fails because the org row is missing (FK violation,23503) surfaces as itself rather than as a re-read that found nothing.None, and that a non-uniqueIntegrityErroris not swallowed by the recovery read.Why a savepoint rather than
ON CONFLICTOrgBudgetStore.record_cycle_baselinessolves its own concurrent-insert problem withinsert(...).on_conflict_do_nothing(constraint=...), so it is fair to ask why this path does not. Two reasons:create_settingsis a multi-row unit of work. It inserts the settings row and threeDEFAULT_THRESHOLDSrows.ON CONFLICT DO NOTHINGon the settings insert alone leaves the loser's three threshold rows committed on top of the winner's three —org_budget_thresholdhas no unique constraint on(org_id, percentage)to conflict against, so there is nothing for a secondON CONFLICTto key on. Making theON CONFLICTroute correct means first adding that constraint in a migration. The savepoint gets the same all-or-nothing outcome from the transaction boundary, and the new threshold-count assertion covers it._get_or_create_settingsreturns anOrgBudgetSettingsthat callers mutate and that_hydrate_cycle_baselinesreads;record_cycle_baselinesreturns nothing and never needs the row back. AnON CONFLICTinsert followed by an unconditional read is two statements for the winner as well as the loser, where the savepoint costs the extra read only on the branch that actually lost.ON CONFLICTstays the right idiom for single-row, fire-and-forget upserts, which is what the store uses it for. This path is neither.Issue Number
N/A
How to Test
Expect
44 passed, 7 skipped. Reverting the service change makestest_settings_row_is_created_once_when_two_requests_racefail withIntegrityErrorfrom the second request.Video/Screenshots
N/A — no UI change.
Type
Notes
This is the only one of the set whose reproduction test was not already committed — it is added here rather than un-skipped.
The recovery re-read depends on READ COMMITTED, where each statement takes a fresh snapshot and so sees the winner's committed row. Under REPEATABLE READ the loser's snapshot predates that commit, the re-read returns
None, and the originalIntegrityErroris re-raised. That is the Postgres default and this session sets no isolation level, but the dependency is now stated in a comment because it is invisible in the code.One of a set of draft PRs, each carrying a single defect the Quint model for org budgets surfaced.
🤖 Generated with Claude Code
Enterprise server image for this PR: