fix(robomp): deferred rate-limited submissions

Stored a bounded per-login overflow backlog and promoted deferred events oldest-first as rolling-window capacity became available. Surfaced the deferred state through the dashboard contract and documented admission behavior.

Fixes #5882
This commit is contained in:
roboomp
2026-07-17 17:26:39 +00:00
parent 0f9fceeea4
commit aecef46436
12 changed files with 297 additions and 32 deletions
+42
View File
@@ -368,6 +368,15 @@ def test_migration_adds_classification_to_existing_db(tmp_path: Path) -> None:
assert row.classification is None # column exists, default NULL
database.set_issue_classification("octo/widget#1", "bug")
assert database.get_issue("octo/widget#1").classification == "bug"
assert database.record_event(
delivery_id="deferred",
event_type="issues",
repo="octo/widget",
issue_key="octo/widget#2",
payload={"action": "opened"},
state="deferred",
)
assert database.get_event("deferred").state == "deferred"
database.close()
@@ -466,6 +475,39 @@ def test_admit_submission_dedupes_by_delivery_before_rate_limit(db: Database) ->
assert db.count_submissions_since("alice", since) == 1
def test_deferred_submissions_are_promoted_oldest_first_when_capacity_frees(db: Database) -> None:
since = iso_seconds_ago(60)
for i in range(2):
assert db.record_submission(delivery_id=f"accepted-{i}", login="Alice", repo="octo/widget")
for i in range(2):
assert db.defer_submission_event(
delivery_id=f"deferred-{i}",
event_type="issues",
login="alice",
repo="octo/widget",
issue_key=f"octo/widget#{i + 10}",
payload={"action": "opened"},
cap=2,
reason="rate limit",
)
newer = db.admit_submission(
delivery_id="newer",
login="alice",
repo="octo/widget",
since=iso_seconds_ago(-1),
cap=2,
)
assert not newer.accepted
assert db.promote_deferred_submissions(since=iso_seconds_ago(-1)) == 2
first = db.claim_next_event()
second = db.claim_next_event()
assert first is not None and first.delivery_id == "deferred-0"
assert second is not None and second.delivery_id == "deferred-1"
assert db.count_submissions_since("alice", since) == 4
def test_admit_submission_enforces_cap_atomically_across_connections(tmp_path: Path) -> None:
path = tmp_path / "admission.sqlite"
# Pre-warm: open + migrate the schema once so the two racing threads below
@@ -92,3 +92,25 @@ async def test_dispatch_pr_synchronize_is_noop(
await _make_pool(settings, db)._dispatch(_pr_row("synchronize")) # noqa: SLF001
assert called is False
@pytest.mark.asyncio
async def test_claim_promotes_deferred_submission_after_window_frees(settings: Settings, db: Database) -> None:
assert db.record_submission(delivery_id="accepted", login="alice", repo="octo/widget")
assert db.defer_submission_event(
delivery_id="deferred",
event_type="issues",
login="alice",
repo="octo/widget",
issue_key="octo/widget#8",
payload={"action": "opened", "issue": {"number": 8}},
cap=1,
reason="rate limit",
)
settings.rate_limit_window_seconds = -1
row = await _make_pool(settings, db)._claim_next_unique() # noqa: SLF001
assert row is not None
assert row.delivery_id == "deferred"
assert row.state == "running"
+15 -15
View File
@@ -112,8 +112,8 @@ def test_api_status_reports_runtime_counts_and_inflight(settings: Settings) -> N
assert runtime["uptime_seconds"] >= 0
counts = body["event_counts"]
# All five buckets must be present even when zero — the UI relies on it.
assert set(counts) == {"queued", "running", "done", "failed", "skipped"}
# All six buckets must be present even when zero — the UI relies on it.
assert set(counts) == {"queued", "deferred", "running", "done", "failed", "skipped"}
assert counts["queued"] + counts["running"] == 2 # d-queued + d-running
assert counts["skipped"] == 1
assert counts["running"] >= 1
@@ -905,9 +905,9 @@ def rate_limited_settings(monkeypatch: pytest.MonkeyPatch, env: dict[str, str])
def test_webhook_rate_limits_unknown_submitter_at_default_cap(rate_limited_settings: Settings) -> None:
app = create_app(rate_limited_settings)
with TestClient(app) as client:
# Default cap is 2 → first two queued, third throttled.
# Default cap is 2 → two queue, two enter the bounded backlog, then overflow skips.
states = []
for i in range(3):
for i in range(5):
resp = _post_issue_opened(
client,
delivery=f"d-{i}",
@@ -918,7 +918,7 @@ def test_webhook_rate_limits_unknown_submitter_at_default_cap(rate_limited_setti
assert resp.status_code == 202
states.append(resp.json()["state"])
close_database()
assert states == ["queued", "queued", "skipped"]
assert states == ["queued", "queued", "deferred", "deferred", "skipped"]
def test_webhook_incoming_pr_comment_without_directive_skips_without_counting_budget(
@@ -955,7 +955,7 @@ def test_webhook_incoming_pr_comment_without_directive_skips_without_counting_bu
assert unmapped is not None
assert unmapped.issue_key == "octo/widget#900"
assert "incoming PR comments ignored" in (unmapped.last_error or "")
assert states == ["queued", "queued", "skipped"]
assert states == ["queued", "queued", "deferred"]
def test_webhook_delivery_populates_issue_index(settings: Settings) -> None:
@@ -1015,7 +1015,7 @@ def test_webhook_contributor_gets_higher_cap(rate_limited_settings: Settings) ->
number=299,
association="CONTRIBUTOR",
)
assert resp.json()["state"] == "skipped"
assert resp.json()["state"] == "deferred"
close_database()
@@ -1066,7 +1066,7 @@ def test_webhook_rate_limit_per_user_is_independent(rate_limited_settings: Setti
).json()["state"]
== "queued"
)
# alice's next attempt is skipped.
# alice's next attempt is deferred.
assert (
_post_issue_opened(
client,
@@ -1075,7 +1075,7 @@ def test_webhook_rate_limit_per_user_is_independent(rate_limited_settings: Setti
number=599,
association="NONE",
).json()["state"]
== "skipped"
== "deferred"
)
# bob is untouched.
for i in range(2):
@@ -1105,13 +1105,13 @@ def test_webhook_rate_limited_event_records_reason(rate_limited_settings: Settin
association="NONE",
)
db = get_database(rate_limited_settings.sqlite_path)
skipped = db.get_event("r-2")
deferred = db.get_event("r-2")
close_database()
assert skipped is not None
assert skipped.state == "skipped"
assert skipped.last_error is not None
assert "rate limit" in skipped.last_error
assert "@charlie" in skipped.last_error
assert deferred is not None
assert deferred.state == "deferred"
assert deferred.last_error is not None
assert "rate limit" in deferred.last_error
assert "@charlie" in deferred.last_error
# ---------- /api/github/issues ----------