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:
@@ -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"
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
Reference in New Issue
Block a user