Skip to content

Commit bbcc2f2

Browse files
fix(quotas): admit append sweeps only against the locked plan (#1092)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 742a6d8 commit bbcc2f2

2 files changed

Lines changed: 33 additions & 57 deletions

File tree

oddish/src/oddish/core/endpoints/sweep.py

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,9 @@ async def create_task_sweep_core(
417417
of the *raw* client submission so an honest retry is not spuriously rejected;
418418
when omitted it is computed from ``submission`` as received here.
419419
"""
420+
from oddish.config import QuotaMode
420421
from oddish.core.quota_admission import admit_trials
422+
from oddish.core.quotas import acquire_quota_locks
421423
from oddish.core.sweeps import (
422424
build_task_submission_from_sweep,
423425
build_trial_specs_from_sweep,
@@ -525,17 +527,19 @@ async def create_task_sweep_core(
525527
if submission.append_to_task:
526528
# Lock order must stay ``quota advisory → task row`` to match
527529
# ``cancel_trials_if_quota_reached`` (quota first, then task FOR UPDATE).
528-
# Taking the task row before ``admit_trials`` inverted that order and
530+
# Taking the task row before the quota lock inverted that order and
529531
# deadlocked under concurrent over-quota cancellation.
530532
#
531-
# Also do not take the org quota lock across reconcile: that window can
532-
# take tens of seconds on large tasks and starves every concurrent
533-
# submit / enforcement worker (opaque 500s on /tasks/sweep).
533+
# Admit only against the locked plan: an unlocked estimate can still
534+
# ``QuotaExceeded`` (402) after a concurrent append already filled the
535+
# deficit, even when this request would insert fewer trials or none.
536+
# Hold the quota advisory only across the short locked plan + admit +
537+
# insert — not across the earlier experiment/setup work.
534538
task = await get_task_for_org_core(
535539
session, task_id=submission.task_id, org_id=org_id
536540
)
537541
# Read-only intent from the unlocked snapshot. Applied under FOR UPDATE
538-
# after admission (idempotent flips).
542+
# after the quota lock (idempotent flips).
539543
want_run_analysis = bool(task.run_analysis or submission.run_analysis)
540544
want_run_probe = bool(task.run_probe or submission.run_probe)
541545

@@ -582,20 +586,10 @@ async def create_task_sweep_core(
582586
target_experiment_id = new_experiment_id or (
583587
primary_experiment.id if primary_experiment else None
584588
)
585-
# Unlocked estimate for quota admission only. Authoritative plan is
586-
# rebuilt under the task row lock below so concurrent appends cannot
587-
# both observe the same deficit and overshoot declarative N.
588-
planned_trials, _ = await _plan_append_trials(
589-
session,
590-
task=task,
591-
submission=submission,
592-
target_experiment_id=target_experiment_id,
593-
default_environment=effective_default_env,
594-
allowed_environments=allowed_environments,
595-
)
596-
# Quota advisory lock is acquired here (and held through commit).
597-
await admit_trials(session, org_id, billed_user_id, count=len(planned_trials))
598-
# Task row lock only after quota — same order as enforcement.
589+
# Quota advisory before task FOR UPDATE (ENFORCE only; SHADOW/OFF do
590+
# not take these locks on admit or cancel either).
591+
if settings.quota_mode == QuotaMode.ENFORCE and org_id is not None:
592+
await acquire_quota_locks(session, org_id, billed_user_id)
599593
await session.refresh(task, with_for_update=True)
600594
# Allow flipping task.run_analysis from False to True on append.
601595
# ``run_analysis`` runs at trial-completion time, so updating the
@@ -625,17 +619,8 @@ async def create_task_sweep_core(
625619
default_environment=effective_default_env,
626620
allowed_environments=allowed_environments,
627621
)
628-
if len(trials) > len(planned_trials):
629-
# Rare: deficit grew while we waited (e.g. concurrent failures).
630-
# Re-check the *full* final count — admit_trials does not accumulate
631-
# the earlier estimate (it only adds ``count`` to current inflight),
632-
# so a delta-only top-up would undercount headroom.
633-
await admit_trials(
634-
session,
635-
org_id,
636-
billed_user_id,
637-
count=len(trials),
638-
)
622+
# Authoritative count only (no-op when the locked plan is empty).
623+
await admit_trials(session, org_id, billed_user_id, count=len(trials))
639624

640625
append_submission = submission.model_copy(
641626
update={

oddish/tests/test_quota_lock_try_acquire.py

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -99,45 +99,36 @@ async def after_check():
9999

100100

101101
def test_append_sweep_preserves_quota_then_task_lock_order():
102-
"""Regression: task FOR UPDATE before admit inverted lock order vs cancel.
102+
"""Regression: lock order and admit-against-locked-plan invariants.
103103
104104
Enforcement takes quota advisory first, then task FOR UPDATE. Append must
105-
do the same (admit_trials → task FOR UPDATE) or concurrent cancel deadlocks.
106-
Also: no early acquire_quota_locks across reconcile (starves /tasks/sweep).
107-
Authoritative reconcile must re-run after the task lock so concurrent
108-
appends cannot both insert against the same unlocked deficit.
105+
do the same or concurrent cancel deadlocks. Admit must use the locked plan
106+
only — an unlocked estimate can 402 after a concurrent append already
107+
filled the deficit.
109108
"""
110109
from oddish.core.endpoints import sweep as sweep_mod
111110

112111
source = Path(sweep_mod.__file__).read_text(encoding="utf-8")
113-
assert "await acquire_quota_locks" not in source
114-
assert "from oddish.core.quotas import acquire_quota_locks" not in source
112+
assert "from oddish.core.quotas import acquire_quota_locks" in source
115113
assert "async def _plan_append_trials(" in source
116114

117115
append_start = source.index("if submission.append_to_task:")
118116
# Limit to the append branch (create mode follows in the same function).
119117
append_end = source.index("\n # Create mode", append_start)
120118
append_body = source[append_start:append_end]
121-
admit_at = append_body.index("await admit_trials(")
119+
120+
acquire_at = append_body.index("await acquire_quota_locks(")
122121
for_update_at = append_body.index("with_for_update=True")
123-
assert for_update_at > admit_at, (
124-
"append path must FOR UPDATE the task only after admit_trials "
125-
f"(admit@{admit_at}, for_update@{for_update_at})"
126-
)
127-
plan_calls = [
128-
i
129-
for i in range(len(append_body))
130-
if append_body.startswith("await _plan_append_trials(", i)
131-
]
132-
assert len(plan_calls) >= 2, (
133-
"append must plan once for admit sizing and again under the task lock"
122+
plan_at = append_body.index("await _plan_append_trials(")
123+
admit_at = append_body.index("await admit_trials(")
124+
125+
assert acquire_at < for_update_at < plan_at < admit_at, (
126+
"expected acquire_quota_locks → FOR UPDATE → plan → admit_trials; "
127+
f"got acquire@{acquire_at} for_update@{for_update_at} "
128+
f"plan@{plan_at} admit@{admit_at}"
134129
)
135-
assert plan_calls[0] < admit_at < for_update_at < plan_calls[1], (
136-
"expected unlocked plan → admit → FOR UPDATE → locked re-plan; "
137-
f"got plan@{plan_calls} admit@{admit_at} for_update@{for_update_at}"
130+
assert append_body.count("await _plan_append_trials(") == 1, (
131+
"append must plan once under the task lock, not against an unlocked estimate"
138132
)
139-
# Top-up after a larger locked plan must re-admit the full final count
140-
# (not a delta): admit_trials adds ``count`` to live inflight only.
141-
top_up = append_body[plan_calls[1] :]
142-
assert "count=len(trials)" in top_up
143-
assert "count=len(trials) - len(planned_trials)" not in top_up
133+
assert "planned_trials" not in append_body
134+
assert "count=len(trials)" in append_body[admit_at:]

0 commit comments

Comments
 (0)