Skip to content

Commit 6082dfa

Browse files
authored
fix(ci-insights): Use pytest-timeout as a deadline (#286)
The goal of this change is to use the timeout as a deadline when rerunning tests instead of using it to reduce the number of reruns. Thus, if a rerun take longer to execute, the deadline will work as expected. We now have a unified behavior for the global and the pytest-timeout deadlines. References: MRGFY-6172
1 parent e3fd9b3 commit 6082dfa

File tree

4 files changed

+67
-60
lines changed

4 files changed

+67
-60
lines changed

pytest_mergify/__init__.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -224,16 +224,20 @@ def pytest_runtest_protocol(
224224

225225
timeout_seconds = pytest_timeout._get_item_settings(item).timeout
226226

227+
self.mergify_ci.flaky_detector.set_test_deadline(
228+
test=item.nodeid,
229+
timeout=datetime.timedelta(seconds=timeout_seconds)
230+
if timeout_seconds
231+
else None,
232+
)
233+
227234
rerun_count = 0
228235
for _ in range(
229-
self.mergify_ci.flaky_detector.get_rerun_count_for_test(
230-
test=item.nodeid,
231-
timeout=datetime.timedelta(seconds=timeout_seconds)
232-
if timeout_seconds
233-
else None,
234-
)
236+
self.mergify_ci.flaky_detector.get_rerun_count_for_test(item.nodeid)
235237
):
236-
if self.mergify_ci.flaky_detector.is_deadline_exceeded():
238+
if self.mergify_ci.flaky_detector.is_test_deadline_exceeded(
239+
item.nodeid
240+
):
237241
break
238242

239243
for report in self._reruntestprotocol(item, nextitem):
@@ -313,10 +317,9 @@ def pytest_runtest_teardown(
313317

314318
# The goal here is to keep only function-scoped finalizers during
315319
# reruns and restore higher-scoped finalizers only on the last one.
316-
if (
317-
self.mergify_ci.flaky_detector.is_deadline_exceeded()
318-
or self.mergify_ci.flaky_detector.is_last_rerun_for_test(item.nodeid)
319-
):
320+
if self.mergify_ci.flaky_detector.is_test_deadline_exceeded(
321+
item.nodeid
322+
) or self.mergify_ci.flaky_detector.is_last_rerun_for_test(item.nodeid):
320323
self.mergify_ci.flaky_detector.restore_item_finalizers(item)
321324
else:
322325
self.mergify_ci.flaky_detector.suspend_item_finalizers(item)

pytest_mergify/flaky_detection.py

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ def initial_duration(self) -> datetime.timedelta:
6969
scheduled_rerun_count: int = dataclasses.field(default=0)
7070
"Represents the number of reruns that have been scheduled for this test depending on the budget."
7171

72+
deadline: typing.Optional[datetime.datetime] = dataclasses.field(default=None)
73+
7274
prevented_timeout: bool = dataclasses.field(default=False)
7375

7476
total_duration: datetime.timedelta = dataclasses.field(
@@ -199,9 +201,7 @@ def filter_context_tests_with_session(self, session: _pytest.main.Session) -> No
199201
def is_test_tracked(self, test: str) -> bool:
200202
return test in self._test_metrics
201203

202-
def get_rerun_count_for_test(
203-
self, test: str, timeout: typing.Optional[datetime.timedelta] = None
204-
) -> int:
204+
def get_rerun_count_for_test(self, test: str) -> int:
205205
metrics = self._test_metrics.get(test)
206206
if not metrics:
207207
return 0
@@ -216,20 +216,7 @@ def get_rerun_count_for_test(
216216
metrics.is_processed = True
217217
metrics.scheduled_rerun_count = result
218218

219-
if not timeout:
220-
return result
221-
222-
# NOTE(remyduthu): Leave a margin of 10 %. Better safe than sorry. We
223-
# don't want to crash the CI.
224-
safe_timeout = timeout * 0.9
225-
if metrics.expected_duration() <= safe_timeout:
226-
return result
227-
228-
metrics.prevented_timeout = True
229-
230-
# NOTE(remyduthu): Use the timeout to get the number of reruns. The goal
231-
# is to still have some reruns.
232-
return self._normalize_rerun_count(int(safe_timeout / metrics.initial_duration))
219+
return result
233220

234221
def _normalize_rerun_count(self, count: int) -> int:
235222
result = min(count, self._context.max_test_execution_count)
@@ -239,11 +226,12 @@ def _normalize_rerun_count(self, count: int) -> int:
239226

240227
return result
241228

242-
def is_deadline_exceeded(self) -> bool:
243-
return (
244-
self._deadline is not None
245-
and datetime.datetime.now(datetime.timezone.utc) >= self._deadline
246-
)
229+
def is_test_deadline_exceeded(self, test: str) -> bool:
230+
metrics = self._test_metrics.get(test)
231+
if not metrics or not metrics.deadline:
232+
return False
233+
234+
return datetime.datetime.now(datetime.timezone.utc) >= metrics.deadline
247235

248236
def make_report(self) -> str:
249237
result = "🐛 Flaky detection"
@@ -335,6 +323,26 @@ def set_deadline(self) -> None:
335323
+ self._get_budget_duration()
336324
)
337325

326+
def set_test_deadline(
327+
self, test: str, timeout: typing.Optional[datetime.timedelta] = None
328+
) -> None:
329+
metrics = self._test_metrics.get(test)
330+
if not metrics:
331+
return
332+
333+
metrics.deadline = self._deadline
334+
335+
if not timeout:
336+
return
337+
338+
# Leave a margin of 10 %. Better safe than sorry. We don't want to crash
339+
# the CI.
340+
safe_timeout = timeout * 0.9
341+
timeout_deadline = datetime.datetime.now(datetime.timezone.utc) + safe_timeout
342+
if not metrics.deadline or timeout_deadline < metrics.deadline:
343+
metrics.deadline = timeout_deadline
344+
metrics.prevented_timeout = True
345+
338346
def is_last_rerun_for_test(self, test: str) -> bool:
339347
"Returns true if the given test exists and this is its last rerun."
340348

tests/test_ci_insights.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,9 @@ def pytest_runtest_call(self, item: _pytest.nodes.Item) -> None:
558558
# Simulate a slow execution that reaches the deadline.
559559
if not self.deadline_patched and self.execution_count == 10:
560560
# Set the deadline in the past to stop immediately.
561-
plugin.mergify_ci.flaky_detector._deadline = datetime.datetime.now(
561+
plugin.mergify_ci.flaky_detector._test_metrics[
562+
"test_flaky_detection_budget_deadline_stops_reruns.py::test_new"
563+
].deadline = datetime.datetime.now(
562564
datetime.timezone.utc
563565
) - datetime.timedelta(hours=1)
564566

tests/test_flaky_detection.py

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ def _make_flaky_detection_context(
5454
)
5555

5656

57+
@freezegun.freeze_time(_NOW)
58+
def test_flaky_detector_set_test_deadline() -> None:
59+
detector = InitializedFlakyDetector()
60+
detector._test_metrics["foo"] = flaky_detection._TestMetrics()
61+
62+
# Use global deadline by default.
63+
detector._deadline = _NOW + datetime.timedelta(seconds=10)
64+
detector.set_test_deadline("foo", timeout=None)
65+
assert str(detector._test_metrics["foo"].deadline) == "2025-01-01 00:00:10+00:00"
66+
67+
# Use minimum between global deadline and timeout, if provided.
68+
detector.set_test_deadline("foo", timeout=datetime.timedelta(seconds=15))
69+
assert str(detector._test_metrics["foo"].deadline) == "2025-01-01 00:00:10+00:00"
70+
detector.set_test_deadline("foo", timeout=datetime.timedelta(seconds=5))
71+
assert (
72+
str(detector._test_metrics["foo"].deadline)
73+
== "2025-01-01 00:00:04.500000+00:00" # 10 % margin applied.
74+
)
75+
76+
5777
@freezegun.freeze_time(_NOW)
5878
def test_flaky_detector_get_duration_before_deadline() -> None:
5979
detector = InitializedFlakyDetector()
@@ -181,29 +201,3 @@ def test_flaky_detector_get_rerun_count_for_test_with_fast_test() -> None:
181201
detector.set_deadline()
182202

183203
assert detector.get_rerun_count_for_test("foo") == 1000
184-
185-
186-
@freezegun.freeze_time(_NOW)
187-
def test_flaky_detector_get_rerun_count_for_test_with_timeout() -> None:
188-
detector = InitializedFlakyDetector()
189-
detector._context = _make_flaky_detection_context(
190-
min_test_execution_count=5,
191-
min_budget_duration_ms=4000,
192-
max_test_execution_count=1000,
193-
)
194-
detector._test_metrics = {
195-
"foo": flaky_detection._TestMetrics(
196-
initial_call_duration=datetime.timedelta(milliseconds=4),
197-
),
198-
}
199-
detector.set_deadline()
200-
201-
# 4 ms * 1000 = 4000 ms budget, but with a timeout of 500 ms and a 10%
202-
# safety margin (500 * 0.9 = 450 ms), we can only rerun 112 times (450 ms /
203-
# 4 ms).
204-
assert (
205-
detector.get_rerun_count_for_test(
206-
"foo", timeout=datetime.timedelta(milliseconds=500)
207-
)
208-
== 112
209-
)

0 commit comments

Comments
 (0)