Skip to content

Commit 5cffa76

Browse files
committed
Update
[ghstack-poisoned]
2 parents 4095d0c + b1e530c commit 5cffa76

4 files changed

Lines changed: 63 additions & 17 deletions

File tree

greenlight/src/greenlight/review.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,10 @@ def _is_trusted(login: str | None) -> bool:
7373
return login is not None and login.lower() in _TRUSTED_LOWER
7474

7575

76-
# The fan-out's aggregate GitHub request rate is workers / seconds-between-requests; both are
77-
# tuned down together to stay under GitHub's secondary (burst-rate) limit, which the prior
78-
# 8-worker / default-paced pool tripped. Raising either without lowering the other raises the
79-
# burst rate.
76+
# Aggregate fan-out request rate is workers / seconds-between-requests: more workers or a
77+
# shorter interval raise it. Cutting workers (8->4) and lengthening the interval (0.25s->0.5s)
78+
# both lower it, to ~8 req/s, keeping the fan-out under GitHub's secondary (burst-rate) limit
79+
# that the prior 8-worker / 0.25s pool tripped.
8080
_FINGERPRINT_WORKERS = 4
8181
_FINGERPRINT_SECONDS_BETWEEN_REQUESTS = 0.5
8282

@@ -257,6 +257,7 @@ def run(
257257
else:
258258
fingerprint_numbers = pr_numbers
259259
failed: list[int] = []
260+
abandoned: list[int] = []
260261
skips: list[tuple[int, ReviewSkip]] = []
261262
worker_count = min(_FINGERPRINT_WORKERS, len(fingerprint_numbers))
262263
# PyGithub is not thread-safe, so each concurrent task borrows a client for its
@@ -279,6 +280,7 @@ def run(
279280
now=evaluated_at,
280281
timeout=timeout,
281282
failed=failed,
283+
abandoned=abandoned,
282284
skips=skips,
283285
force=force,
284286
)
@@ -295,9 +297,17 @@ def run(
295297
now=evaluated_at,
296298
timeout=timeout,
297299
failed=failed,
300+
abandoned=abandoned,
298301
skips=skips,
299302
force=force,
300303
)
304+
if abandoned:
305+
logger.warning(
306+
"rate limit hit: abandoned %d of %d fingerprint(s) (not evaluated); %d candidate(s) dispatchable",
307+
len(abandoned),
308+
len(fingerprint_numbers),
309+
len(pending),
310+
)
301311
dispatch_failed = scan_runner._dispatch_pending(
302312
client, pending, ref=ref, max_dispatches=max_dispatches, dispatch=dispatch, emit_dispatched=emit_dispatched
303313
)
@@ -308,10 +318,12 @@ def run(
308318
scan_runner.post_refusals(
309319
client, TARGET_REPO, skips, bot_login=bot_login, get_pr=get_pr, upsert_comment=upsert_comment
310320
)
311-
if failed or dispatch_failed:
321+
if failed or dispatch_failed or abandoned:
312322
errors: list[str] = []
313323
if failed:
314324
errors.append(f"{len(failed)} PR(s) failed during scan: {sorted(failed)}")
315325
if dispatch_failed:
316326
errors.append(f"failed to dispatch {len(dispatch_failed)} PR(s): {sorted(dispatch_failed)}")
327+
if abandoned:
328+
errors.append(f"{len(abandoned)} PR(s) abandoned due to rate limit: {sorted(abandoned)}")
317329
raise RuntimeError("; ".join(errors))

greenlight/src/greenlight/scan_runner.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,17 @@ def _evaluate_pr(
102102
now: datetime,
103103
timeout: timedelta,
104104
failed: list[int],
105+
abandoned: list[int],
105106
skips: list[tuple[int, ReviewSkip]],
106107
force: bool,
107108
) -> _Candidate | None:
108109
try:
109110
result = future.result()
110111
# A cancelled task never ran its fingerprint (an earlier task hit a rate limit and tripped
111-
# the shared cancel event), so it is neither a candidate nor a failure: drop it silently.
112+
# the shared cancel event), so it is not a failure: record it as abandoned (never evaluated),
113+
# kept distinct from `failed` so the end-of-scan signal can report both without conflating them.
112114
if result is _CANCELLED:
115+
abandoned.append(number)
113116
return None
114117
# A ReviewSkip is a human decision, never a fingerprint failure: check before unpacking
115118
# so it is collected/dropped, not mistaken for an error and appended to failed.
@@ -154,6 +157,7 @@ def _fingerprint_all(
154157
now: datetime,
155158
timeout: timedelta,
156159
failed: list[int],
160+
abandoned: list[int],
157161
skips: list[tuple[int, ReviewSkip]],
158162
force: bool,
159163
) -> list[_Candidate]:
@@ -176,7 +180,15 @@ def _fingerprint_all(
176180
pending: list[_Candidate] = []
177181
for number in pr_numbers:
178182
candidate = _evaluate_pr(
179-
number, futures[number], states, now=now, timeout=timeout, failed=failed, skips=skips, force=force
183+
number,
184+
futures[number],
185+
states,
186+
now=now,
187+
timeout=timeout,
188+
failed=failed,
189+
abandoned=abandoned,
190+
skips=skips,
191+
force=force,
180192
)
181193
if candidate is not None:
182194
pending.append(candidate)
@@ -196,6 +208,7 @@ def _fingerprint_until_dispatchable(
196208
now: datetime,
197209
timeout: timedelta,
198210
failed: list[int],
211+
abandoned: list[int],
199212
skips: list[tuple[int, ReviewSkip]],
200213
force: bool,
201214
) -> list[_Candidate]:
@@ -230,6 +243,7 @@ def _fingerprint_until_dispatchable(
230243
now=now,
231244
timeout=timeout,
232245
failed=failed,
246+
abandoned=abandoned,
233247
skips=skips,
234248
force=force,
235249
)

greenlight/tests/test_review.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,7 +1292,7 @@ def boom(_client, _number, _authorized, _skip):
12921292
assert not cancel_event.is_set()
12931293

12941294

1295-
def test_rate_limit_abandons_remaining_fingerprints(make_config, monkeypatch):
1295+
def test_rate_limit_abandons_remaining_fingerprints(make_config, monkeypatch, caplog):
12961296
from github import RateLimitExceededException
12971297

12981298
# Pin one worker for a deterministic FIFO order: PR1 runs first and trips the cancel event before
@@ -1310,7 +1310,7 @@ def fingerprint(_client, number, _authorized, _skip):
13101310
def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13111311
dispatched.append(number)
13121312

1313-
with pytest.raises(RuntimeError, match=r"1 PR\(s\) failed during scan: \[1\]"):
1313+
with caplog.at_level(logging.WARNING, logger="greenlight"), pytest.raises(RuntimeError) as excinfo:
13141314
review.run(
13151315
make_config(github_token="t"),
13161316
build_github=lambda _token, **_kwargs: _CLIENT,
@@ -1323,13 +1323,20 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13231323
)
13241324

13251325
# PR1's rate limit sets the shared cancel event, so the queued PR2/PR3 short-circuit to _CANCELLED
1326-
# without ever calling fingerprint (only PR1 appears). A cancelled task is not a failure, so only
1327-
# PR1 lands in `failed`, and nothing dispatches.
1326+
# without ever calling fingerprint (only PR1 appears). A cancelled task is not a failure: PR1 lands
1327+
# in `failed`, PR2/PR3 are counted as abandoned (not failed), and nothing dispatches.
13281328
assert fingerprinted == [1]
13291329
assert dispatched == []
1330+
# The abandonment is surfaced, never silently dropped: a warning names the count, and the
1331+
# end-of-scan RuntimeError carries both the failed PR and the abandoned ones distinctly so the
1332+
# scan still fails closed on an incomplete pass.
1333+
message = str(excinfo.value)
1334+
assert "1 PR(s) failed during scan: [1]" in message
1335+
assert "2 PR(s) abandoned due to rate limit: [2, 3]" in message
1336+
assert "abandoned 2 of 3" in caplog.text
13301337

13311338

1332-
def test_rate_limit_still_dispatches_earlier_completed_candidate(make_config, monkeypatch):
1339+
def test_rate_limit_still_dispatches_earlier_completed_candidate(make_config, monkeypatch, caplog):
13331340
from github import RateLimitExceededException
13341341

13351342
monkeypatch.setattr(review, "_FINGERPRINT_WORKERS", 1)
@@ -1345,7 +1352,7 @@ def fingerprint(_client, number, _authorized, _skip):
13451352
def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13461353
dispatched.append(number)
13471354

1348-
with pytest.raises(RuntimeError, match=r"1 PR\(s\) failed during scan: \[2\]"):
1355+
with caplog.at_level(logging.WARNING, logger="greenlight"), pytest.raises(RuntimeError) as excinfo:
13491356
review.run(
13501357
make_config(github_token="t"),
13511358
build_github=lambda _token, **_kwargs: _CLIENT,
@@ -1359,9 +1366,15 @@ def fake_dispatch(_client, number, _head_sha, _eval_hash, _ref):
13591366

13601367
# PR1 completes as a candidate before PR2 hits the rate limit, so the partial success is preserved
13611368
# -- PR1 still dispatches. PR2 trips the cancel event, so PR3 short-circuits without being
1362-
# fingerprinted, and only PR2 lands in `failed`.
1369+
# fingerprinted: PR2 lands in `failed`, PR3 is counted as abandoned (not failed).
13631370
assert fingerprinted == [1, 2]
13641371
assert dispatched == [1]
1372+
# The failure and the abandonment are surfaced distinctly in the fail-closed signal, and the
1373+
# abandonment is logged as its own warning rather than folded into the failure count.
1374+
message = str(excinfo.value)
1375+
assert "1 PR(s) failed during scan: [2]" in message
1376+
assert "1 PR(s) abandoned due to rate limit: [3]" in message
1377+
assert "abandoned 1 of 3" in caplog.text
13651378

13661379

13671380
def test_rate_limit_abandonment_breaks_max_dispatch_batches(make_config, monkeypatch):

greenlight/tests/test_scan_runner.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ def upsert(pr, **_kwargs):
167167

168168
def test_evaluate_pr_iteration_timeout_propagates():
169169
failed: list[int] = []
170+
abandoned: list[int] = []
170171
skips: list[tuple[int, ReviewSkip]] = []
171172
future = cast(
172173
"Future[tuple[str, str] | ReviewSkip | scan_runner._Cancelled]", _RaisingFuture(IterationTimeout("boom"))
@@ -182,11 +183,13 @@ def test_evaluate_pr_iteration_timeout_propagates():
182183
now=datetime(2026, 1, 1, tzinfo=UTC),
183184
timeout=timedelta(hours=1),
184185
failed=failed,
186+
abandoned=abandoned,
185187
skips=skips,
186188
force=False,
187189
)
188190

189191
assert failed == []
192+
assert abandoned == []
190193
assert skips == []
191194

192195

@@ -264,8 +267,9 @@ def fingerprint(*_args):
264267
assert pool.get_nowait() is client
265268

266269

267-
def test_evaluate_pr_cancelled_result_returns_none_without_failing():
270+
def test_evaluate_pr_cancelled_result_records_abandoned_not_failed():
268271
failed: list[int] = []
272+
abandoned: list[int] = []
269273
skips: list[tuple[int, ReviewSkip]] = []
270274
future = cast(
271275
"Future[tuple[str, str] | ReviewSkip | scan_runner._Cancelled]", _ResultFuture(scan_runner._CANCELLED)
@@ -278,13 +282,16 @@ def test_evaluate_pr_cancelled_result_returns_none_without_failing():
278282
now=datetime(2026, 1, 1, tzinfo=UTC),
279283
timeout=timedelta(hours=1),
280284
failed=failed,
285+
abandoned=abandoned,
281286
skips=skips,
282287
force=False,
283288
)
284289

285-
# A cancelled task was never attempted, so it is neither a candidate nor a failure: it is dropped
286-
# without landing in `failed` (only tasks that actually hit the limit do).
290+
# A cancelled task was never attempted, so it is not a candidate and not a failure: it is recorded
291+
# as abandoned (distinct from `failed`, which holds only tasks that actually hit the limit) so the
292+
# scan can surface it instead of dropping it silently.
287293
assert result is None
294+
assert abandoned == [5]
288295
assert failed == []
289296
assert skips == []
290297

0 commit comments

Comments
 (0)