Skip to content

Commit 0b904ae

Browse files
authored
[OPIK-8197] [BE] fix: unhang PythonEvaluatorServiceTest and bound every test with a timeout (#8108)
1 parent c9eb439 commit 0b904ae

5 files changed

Lines changed: 184 additions & 49 deletions

File tree

.github/scripts/discover-backend-tests.sh

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ set -euo pipefail
44
NUM_GROUPS=16
55
UNIT_TIMEOUT=10
66
INTEGRATION_TIMEOUT=20
7+
# Per-test timeout handed to JUnit, overriding the default in junit-platform.properties.
8+
# CI reruns failures 3 times, so a deterministic hang costs 4x these values: 8m against the 10m
9+
# unit wall, 16m against the 20m integration wall. Both must stay under their job timeout above,
10+
# or the job is cancelled before Surefire can name the offending test.
11+
UNIT_TEST_TIMEOUT=2m
12+
INTEGRATION_TEST_TIMEOUT=4m
713
TEST_DIR="src/test/java"
814
PATTERN="DropwizardAppExtensionProvider\|MySQLContainer\|ClickHouseContainer\|RedisContainer\|MinIOContainer"
915

@@ -62,9 +68,9 @@ done
6268

6369
# Build JSON matrix: unit tests + N integration groups
6470
matrix="{\"include\":["
65-
matrix+="{\"name\":\"Unit Tests\",\"tests\":\"$unit_list\",\"timeout\":$UNIT_TIMEOUT}"
71+
matrix+="{\"name\":\"Unit Tests\",\"tests\":\"$unit_list\",\"timeout\":$UNIT_TIMEOUT,\"testTimeout\":\"$UNIT_TEST_TIMEOUT\"}"
6672
for ((i=1; i<=NUM_GROUPS; i++)); do
67-
matrix+=",{\"name\":\"Integration Group $i\",\"tests\":\"${group_list[$i]}\",\"timeout\":$INTEGRATION_TIMEOUT}"
73+
matrix+=",{\"name\":\"Integration Group $i\",\"tests\":\"${group_list[$i]}\",\"timeout\":$INTEGRATION_TIMEOUT,\"testTimeout\":\"$INTEGRATION_TEST_TIMEOUT\"}"
6874
done
6975
matrix+="]}"
7076
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"

.github/workflows/backend_tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ jobs:
8484
-Dtest="${{ matrix.tests }}"
8585
-Dmaven.test.failure.ignore=true
8686
-Dsurefire.rerunFailingTestsCount=3
87+
-Djunit.jupiter.execution.timeout.testable.method.default="${{ matrix.testTimeout }}"
8788
8889
- name: Publish Test Report
8990
uses: EnricoMi/publish-unit-test-result-action/linux@v2
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.comet.opik;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.io.IOException;
7+
import java.io.UncheckedIOException;
8+
import java.time.Duration;
9+
import java.util.Properties;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
13+
/**
14+
* Asserts that the shipped per-test timeout still fits CI's retry budget.
15+
* <p>
16+
* CI runs with {@code -Dsurefire.rerunFailingTestsCount=3}, and a timeout is an ordinary failure to
17+
* Surefire, so a deterministic hang is attempted 4 times. If 4x the bound exceeds the job wall, the
18+
* job is cancelled before Surefire can publish the named failure report -- losing exactly the
19+
* diagnostic the timeout was added to provide. A 5m bound was caught by review for that reason.
20+
* <p>
21+
* Deliberately narrow: it reads the shipped value and checks the arithmetic. It does not
22+
* re-implement JUnit's timeout grammar (JUnit validates that itself, throwing
23+
* {@code DateTimeParseException} at engine startup) and does not scrape the workflow or discovery
24+
* script for literal substrings, which would break on unrelated formatting changes there.
25+
*/
26+
@DisplayName("Test Timeout Guard")
27+
class TestTimeoutGuardTest {
28+
29+
private static final String TIMEOUT_PROPERTY = "junit.jupiter.execution.timeout.testable.method.default";
30+
31+
/** The wall this file's value has to fit: INTEGRATION_TIMEOUT in discover-backend-tests.sh. */
32+
private static final Duration INTEGRATION_JOB_WALL = Duration.ofMinutes(20);
33+
34+
/** CI runs -Dsurefire.rerunFailingTestsCount=3, and a timeout is an ordinary failure. */
35+
private static final int ATTEMPTS_PER_HANG = 4;
36+
37+
@Test
38+
@DisplayName("the shipped default survives CI's retry budget")
39+
void shippedDefaultFitsRetryBudget() {
40+
// Read the file, never System.getProperty: CI always passes a -D override, so consulting it
41+
// would mean the shipped default -- the value local runs actually inherit -- went unchecked.
42+
var shipped = loadShippedTimeout();
43+
44+
var budget = shipped.multipliedBy(ATTEMPTS_PER_HANG);
45+
46+
assertThat(budget)
47+
.as("%d attempts at the shipped '%s' must fit inside the %s job wall",
48+
ATTEMPTS_PER_HANG, shipped, INTEGRATION_JOB_WALL)
49+
.isPositive()
50+
.isLessThan(INTEGRATION_JOB_WALL);
51+
}
52+
53+
@Test
54+
@DisplayName("the timeout is scoped to testable methods so container startup stays exempt")
55+
void timeoutIsScopedToTestableMethods() {
56+
// A blanket junit.jupiter.execution.timeout.default would also bound @BeforeAll/@BeforeEach,
57+
// making slow, variable Testcontainers startup the thing that fails instead of the test.
58+
assertThat(loadProperties().stringPropertyNames())
59+
.contains(TIMEOUT_PROPERTY)
60+
.doesNotContain("junit.jupiter.execution.timeout.default");
61+
}
62+
63+
private static Duration loadShippedTimeout() {
64+
var value = loadProperties().getProperty(TIMEOUT_PROPERTY);
65+
assertThat(value).as("%s must be configured", TIMEOUT_PROPERTY).isNotBlank();
66+
67+
// Only the units the shipped value is ever expected to use. Anything else is a deliberate
68+
// failure rather than a guess: silently coercing an unrecognised value to a small number is
69+
// how an oversized bound would slip past the assertion above.
70+
var trimmed = value.trim();
71+
assertThat(trimmed).as("shipped timeout should be expressed in whole minutes or seconds")
72+
.matches("[1-9]\\d* ?[ms]");
73+
74+
var amount = Long.parseLong(trimmed.replaceAll("[^0-9]", ""));
75+
return trimmed.endsWith("m") ? Duration.ofMinutes(amount) : Duration.ofSeconds(amount);
76+
}
77+
78+
private static Properties loadProperties() {
79+
var properties = new Properties();
80+
try (var in = TestTimeoutGuardTest.class.getResourceAsStream("/junit-platform.properties")) {
81+
assertThat(in).as("junit-platform.properties must be on the test classpath").isNotNull();
82+
properties.load(in);
83+
} catch (IOException e) {
84+
throw new UncheckedIOException(e);
85+
}
86+
return properties;
87+
}
88+
}

0 commit comments

Comments
 (0)