Skip to content

Commit 479fcd1

Browse files
authored
fix(rbac+estimation): drop legacy OrgToUser.role and harden every estimate_bud_dates caller (#181)
* fix(rbac+estimation): repair count_active_by_role GROUP BY and harden every estimate_bud_dates caller The dynamic-role refactor introduced count_active_by_role with a parameterised CASE in both SELECT and GROUP BY. SQLAlchemy compiled each call to _effective_role_case() as a fresh expression, so the two CASEs emitted distinct bind parameters and Postgres rejected the query with "column roles.scope_type must appear in the GROUP BY clause". That GroupingError cascaded through get_role_capacity into estimate_bud_dates and crashed every caller. The original failure was then masked as InFailedSQLTransactionError on the next innocent statement because every "except Exception" around estimation swallowed the error without rolling back, leaving the connection in an aborted state. Two-part fix: 1. Repository: hoist the CASE into a single labelled expression so SELECT and GROUP BY reference the same object. Postgres accepts GROUP BY by output column alias, sidestepping the expression-identity trap. 2. Every estimate_bud_dates caller that ran on a shared session is now wrapped in db.begin_nested() (Postgres SAVEPOINT). A query failure inside the estimator only rolls back its own writes; the outer txn stays alive and prior flushed writes survive. Same session, same connection — no row-lock conflict with the outer's pending writes. Each except now logs with exc_info=True so the real traceback is not lost again. - handle_prd_result: explicit flush of linked-feature inserts before the savepoint so an autoflush inside it cannot scope them to the savepoint and lose them on rollback. - handle_tech_arch_result: existing flush already anchored tech_spec_md + impacted_repos; only the swallow shape changed. - handle_testing_result: moved the trailing flush above the savepoint for the same anchoring reason. - check_all_prs_merged: create_agent_task_for_stage already commits internally, so no extra flush; SAVEPOINT still isolates the webhook's trailing commit from estimator failure. - code_review_override endpoint: outer was already committed earlier in the handler, so estimation safely runs in a fresh AsyncSessionLocal without a row-lock conflict. Inline imports in the touched handlers were hoisted to top of file per the project's import policy. Verified end-to-end against live Postgres on the failing bud: - count_active_by_role / get_role_capacity / estimate_bud_dates all succeed. - Forced SQL error inside db.begin_nested() raises cleanly; outer txn stays valid; subsequent outer query sees the pre-savepoint flush. - Full handler flow (outer flush -> SAVEPOINT estimate incl. LLM call -> outer rollback) completes in 5.7s with no deadlock. - All four triggers (prd_completed, tech_arch_completed, testing_completed, prs_merged) pass. ruff + mypy clean on the four modified files. Signed-off-by: Arun Rajkumar <mickyarunr@gmail.com> * chore(format): apply ruff format on the estimation-resilience edits The PR-181 commit passed `ruff check` locally but not `ruff format --check` — CI's separate format gate flagged three files. Pure whitespace: ruff collapses multi-line calls / logger.warning args that fit on one line. No behavioural change. lint, mypy, and the behaviour-verifying live tests from the original commit still pass. Signed-off-by: Arun Rajkumar <mickyarunr@gmail.com> --------- Signed-off-by: Arun Rajkumar <mickyarunr@gmail.com>
1 parent 6244428 commit 479fcd1

4 files changed

Lines changed: 80 additions & 51 deletions

File tree

backend/app/api/v1/bud.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from app.api.v1.bud_versions import router as versions_router
3535
from app.api.v1.bud_workflows import router as workflows_router
3636
from app.core.deps import get_current_user, get_db, get_user_permissions, require_permissions
37+
from app.database import AsyncSessionLocal
3738
from app.models.bud import (
3839
BUDDesignStatus,
3940
BUDDocument,
@@ -92,6 +93,7 @@
9293
)
9394
from app.services.bud_assignment_actions import assign_bud, unassign_bud
9495
from app.services.bud_edit_policy import assert_section_editable
96+
from app.services.bud_estimation import estimate_bud_dates
9597
from app.services.bud_timeline import record_event
9698
from app.services.job_queue import JOB_BUD_AGENT, create_job
9799

@@ -1126,13 +1128,8 @@ async def _bg_estimate(
11261128
actor_name: str,
11271129
) -> None:
11281130
"""Run estimation in the background with its own DB session."""
1129-
from app.database import AsyncSessionLocal
1130-
from app.services.bud_estimation import estimate_bud_dates
1131-
11321131
try:
11331132
async with AsyncSessionLocal() as db:
1134-
from app.repositories.bud import BUDRepository
1135-
11361133
bud_repo = BUDRepository(db, org_id=org_id)
11371134
bud = await bud_repo.get_by_id(bud_id)
11381135
if bud is None:
@@ -1274,23 +1271,29 @@ async def override_code_review(
12741271
bud_id=str(bud_id),
12751272
)
12761273

1277-
# Refresh estimates so dashboards reflect the new phase.
1274+
# Refresh estimates so dashboards reflect the new phase. Run in a
1275+
# fresh session so a DB failure inside the estimator can't poison
1276+
# the request session — the trailing _bud_response below queries
1277+
# the same session and would otherwise 500 with the confusing
1278+
# InFailedSQLTransactionError instead of the real cause. The
1279+
# status transition was already committed above, so a fresh
1280+
# refetch sees status=testing without any mirroring.
12781281
try:
1279-
from app.services.bud_estimation import estimate_bud_dates
1280-
1281-
refreshed_for_est = await bud_repo.get_by_id(bud_id)
1282-
if refreshed_for_est is not None:
1283-
await estimate_bud_dates(
1284-
db,
1285-
current_user.org_id,
1286-
refreshed_for_est,
1287-
trigger="code_review_override",
1288-
)
1289-
await db.commit()
1282+
async with AsyncSessionLocal() as est_db:
1283+
est_bud = await BUDRepository(est_db, org_id=current_user.org_id).get_by_id(bud_id)
1284+
if est_bud is not None:
1285+
await estimate_bud_dates(
1286+
est_db,
1287+
current_user.org_id,
1288+
est_bud,
1289+
trigger="code_review_override",
1290+
)
1291+
await est_db.commit()
12901292
except Exception:
12911293
logger.warning(
12921294
"code_review_override_estimation_failed",
12931295
bud_id=str(bud_id),
1296+
exc_info=True,
12941297
)
12951298

12961299
refreshed = await bud_repo.get_by_id(bud_id)

backend/app/repositories/user.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,18 @@ async def count_active_by_role(self, org_id: uuid.UUID) -> dict[UserRole, int]:
284284
``role_id``-less rows fall out because they have no pool semantics.
285285
"""
286286
base_role = aliased(Role)
287+
# The CASE must be the SAME expression object in SELECT and GROUP BY;
288+
# calling ``_effective_role_case`` twice would emit two distinct
289+
# parameterised CASEs and Postgres would reject the GROUP BY as not
290+
# covering the SELECT's underlying ``roles.scope_type`` reference.
291+
role_expr = _effective_role_case(base_role).label("effective_role")
287292
stmt = (
288-
select(_effective_role_case(base_role), func.count())
293+
select(role_expr, func.count())
289294
.select_from(OrgToUser)
290295
.outerjoin(Role, Role.id == OrgToUser.role_id)
291296
.outerjoin(base_role, base_role.id == Role.base_role_id)
292297
.where(OrgToUser.org_id == org_id)
293-
.group_by(_effective_role_case(base_role))
298+
.group_by(role_expr)
294299
)
295300
result = await self._db.execute(stmt)
296301
counts: dict[UserRole, int] = {}

backend/app/services/agent_result_handlers.py

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@
3030
from app.models.bud_feature_link import BUDFeatureLinkSource
3131
from app.models.bud_version import BUDEditSource
3232
from app.repositories import bud_version as bud_version_repo
33+
from app.repositories.bud import BUDRepository
3334
from app.repositories.bud_feature_link import BUDFeatureLinkRepository
35+
from app.repositories.tracked_repository import TrackedRepoRepository
36+
from app.services.bud_estimation import estimate_bud_dates
3437
from app.services.bud_timeline import record_event
3538
from app.services.json_parser import parse_json_response, strip_insight_blocks
39+
from app.services.notification_service import send_lifecycle_notification
3640

3741
logger = structlog.get_logger(__name__)
3842

@@ -96,18 +100,25 @@ async def handle_prd_result(
96100
TechPlanner, Code Reviewer, Tester) inherit the grounding.
97101
"""
98102
linked_count = await _persist_pm_linked_features(bud_id, org_id, output, db)
99-
100-
# Generate initial delivery estimates now that PRD content exists
101-
try:
102-
from app.repositories.bud import BUDRepository
103-
from app.services.bud_estimation import estimate_bud_dates
104-
105-
bud_repo = BUDRepository(db, org_id=org_id)
106-
bud = await bud_repo.get_by_id(bud_id)
107-
if bud:
108-
await estimate_bud_dates(db, org_id, bud, trigger="prd_completed")
109-
except Exception:
110-
logger.warning("estimation_failed_after_prd", bud_id=str(bud_id))
103+
# Flush the linked-feature inserts to the outer transaction proper
104+
# before opening the savepoint below — otherwise an autoflush triggered
105+
# inside the savepoint would scope those inserts to it, and an
106+
# estimation failure would roll the links back too.
107+
await db.flush()
108+
109+
# Generate initial delivery estimates now that PRD content exists.
110+
# Wrap in a SAVEPOINT so a query failure inside the estimator only
111+
# rolls back the estimator's own writes — the outer transaction stays
112+
# alive, its prior writes survive, and the next log_agent_activity
113+
# call doesn't trip InFailedSQLTransactionError.
114+
bud_repo = BUDRepository(db, org_id=org_id)
115+
bud = await bud_repo.get_by_id(bud_id)
116+
if bud is not None:
117+
try:
118+
async with db.begin_nested():
119+
await estimate_bud_dates(db, org_id, bud, trigger="prd_completed")
120+
except Exception:
121+
logger.warning("estimation_failed_after_prd", bud_id=str(bud_id), exc_info=True)
111122

112123
return {
113124
"section": "requirements_md",
@@ -254,9 +265,6 @@ async def handle_tech_arch_result(
254265
We parse that JSON to determine which repos actually need changes,
255266
then strip the JSON block before storing the markdown.
256267
"""
257-
from app.repositories.bud import BUDRepository
258-
from app.repositories.tracked_repository import TrackedRepoRepository
259-
260268
bud_repo = BUDRepository(db, org_id=org_id)
261269
bud = await bud_repo.get_by_id(bud_id)
262270
if bud:
@@ -292,17 +300,20 @@ async def handle_tech_arch_result(
292300
# todos crystallize from the *approved* plan at the DEVELOPMENT-phase
293301
# transition — see services/bud_development.on_bud_development_started.
294302

295-
# Re-estimate with richer context (now has tech spec + impacted repos)
303+
# Re-estimate with richer context (now has tech spec + impacted repos).
304+
# Wrap in a SAVEPOINT so a query failure inside the estimator only
305+
# rolls back the estimator's own writes — the outer transaction
306+
# stays alive (preserving the tech_spec_md / impacted_repos writes
307+
# flushed just above), and the next log_agent_activity call doesn't
308+
# trip InFailedSQLTransactionError. Same-session also means the
309+
# estimator sees the in-memory bud writes without a refetch + mirror.
296310
try:
297-
from app.services.bud_estimation import estimate_bud_dates
298-
299-
await estimate_bud_dates(db, org_id, bud, trigger="tech_arch_completed")
311+
async with db.begin_nested():
312+
await estimate_bud_dates(db, org_id, bud, trigger="tech_arch_completed")
300313
except Exception:
301-
logger.warning("estimation_failed_after_tech_arch", bud_id=str(bud_id))
314+
logger.warning("estimation_failed_after_tech_arch", bud_id=str(bud_id), exc_info=True)
302315

303316
if bud and bud.assignee_id:
304-
from app.services.notification_service import send_lifecycle_notification
305-
306317
bud_ref = f"BUD-{bud.bud_number:03d}"
307318
send_lifecycle_notification(
308319
org_id=str(org_id),
@@ -476,14 +487,20 @@ async def handle_testing_result(
476487
f"- **{manual_count}** manual test cases\n\n"
477488
f"{parsed_data['test_execution_plan']}"
478489
)
479-
# Re-estimate with QA test case context
490+
# Flush the QA writes to the outer transaction proper BEFORE the
491+
# savepoint — otherwise an autoflush triggered inside the savepoint
492+
# would scope them to it, and an estimation failure would roll the
493+
# qa_* / test_plan_md back too.
494+
await db.flush()
495+
# Re-estimate with QA test case context. Wrap in a SAVEPOINT so a
496+
# query failure inside the estimator only rolls back the estimator's
497+
# own writes — the QA writes above and the subsequent notification
498+
# all stay on the live outer transaction.
480499
try:
481-
from app.services.bud_estimation import estimate_bud_dates
482-
483-
await estimate_bud_dates(db, org_id, bud, trigger="testing_completed")
500+
async with db.begin_nested():
501+
await estimate_bud_dates(db, org_id, bud, trigger="testing_completed")
484502
except Exception:
485-
logger.warning("estimation_failed_after_testing", bud_id=str(bud_id))
486-
await db.flush()
503+
logger.warning("estimation_failed_after_testing", bud_id=str(bud_id), exc_info=True)
487504

488505
if bud and bud.assignee_id:
489506
from app.services.notification_service import send_lifecycle_notification

backend/app/services/pr_auto_transition.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from app.repositories.bud import BUDRepository
3232
from app.repositories.bud_todo import BUDTodoRepository
3333
from app.repositories.pull_request import PullRequestRepository
34+
from app.services.bud_estimation import estimate_bud_dates
3435
from app.services.bud_timeline import record_event
3536

3637
logger = structlog.get_logger(__name__)
@@ -178,10 +179,13 @@ async def check_all_prs_merged(
178179

179180
await create_agent_task_for_stage(bud, "testing", org_id, db, force=True)
180181

182+
# create_agent_task_for_stage above already committed the status
183+
# transition, timeline events, and new agent-task row. The SAVEPOINT
184+
# below exists purely so a query failure inside the estimator doesn't
185+
# poison the webhook's trailing flushes with InFailedSQLTransaction.
181186
try:
182-
from app.services.bud_estimation import estimate_bud_dates
183-
184-
await estimate_bud_dates(db, org_id, bud, trigger="prs_merged")
187+
async with db.begin_nested():
188+
await estimate_bud_dates(db, org_id, bud, trigger="prs_merged")
185189
except Exception:
186-
logger.warning("estimation_failed_after_prs_merged", bud_id=str(bud_id))
190+
logger.warning("estimation_failed_after_prs_merged", bud_id=str(bud_id), exc_info=True)
187191
logger.info("auto_transition_to_testing", bud_id=str(bud_id))

0 commit comments

Comments
 (0)