Skip to content

ci(backend): make backend-test hangs diagnosable - #7154

Draft
corneliusroemer-agent wants to merge 18 commits into
mainfrom
ci/backend-test-hang-diagnostics
Draft

ci(backend): make backend-test hangs diagnosable#7154
corneliusroemer-agent wants to merge 18 commits into
mainfrom
ci/backend-test-hang-diagnostics

Conversation

@corneliusroemer-agent

@corneliusroemer-agent corneliusroemer-agent commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Why

backend-tests dies at the 15-minute cap every so often, and no occurrence was diagnosable — the job recorded nothing about where it stopped. Six occurrences in 3390 runs (~0.18%) going back to 2025-07-09: 19969091729, 24840664509, 31117640303, 31121527329, 32712103886 att 1, 32748302946 att 1. Two of those only show up if you ask for earlier attempts explicitly, because a run that hung and was then re-run green reports as a fast success.

This PR improves logging and log collection and also adds a thread dump collection to be able to inspect exactly where the tests hang.

This has already proved useful, I'll make an issue or fix PRs for the reasons hangs happened (I did ~150 test runs of backend tests to find hangs to investigate).

Worth knowing (according to Claude)

Gradle writes the JUnit XML and HTML only when the test task completes. So an artifact glob of test-results/**/*.xml uploads nothing on a timeout, which is the only case it would be written for. Measured by killing local runs at different points:

state XML HTML test-results/test/binary/
clean pass 74 yes present
killed after all 580 tests ran 0 no 118 KB + 18 MB output-events.bin
killed 12 s in (73 tests passed) 0 no 12 KB + 1.7 MB

Hence keeping the binary store when there's no XML, plus recoverTestReport to rebuild from it. That recovery has a limit worth knowing before relying on it: complete when all the tests had finished and the kill landed while the report was being written, but empty when the run is killed while tests are still running — 73 tests had passed across ~10 classes and none appeared, because Gradle only finalises that store at the end, rather than as each test finishes. So for a hang the useful evidence is the per-test progress log and the thread dumps; the recovery step only helps when a run is killed after the tests are done.

Per-test progress goes to a file rather than the console. Printing every test's start and finish to the job log made ordinary runs unreadable, so it now goes to build/diagnostics/test-progress.log, which the artifact already collects. Each line is written as it happens, so a killed run keeps everything up to the moment it died — checked by killing a run mid-suite, which left 509 lines with the last one naming the test still running. On a failure the job log gets only the tests that started and never finished, which is the part worth having there.

The report's root page is the only place output from outside a test survives. Anything logged before the first test class starts has no test to be attributed to, so Gradle files it as output of the run as a whole, and that appears only in build/reports/tests/test/index.html. It is where a failure to start the test database reports its real cause: on a deliberately broken startup, 74 per-class XML files were written and not one of them contained it. An earlier commit here dropped the HTML report as a duplicate of the XML, which holds for a normal run and not for that one, so the root page alone is back — 57 KB, against 9.4 MB for the whole report.

🚀 Preview: Add preview label to enable

Cornelius Roemer and others added 3 commits August 25, 2026 13:17
`testLogging` only reported FAILED and STANDARD_ERROR, so the `:test` phase
printed nothing between the first class's byte-buddy warning and BUILD
SUCCESSFUL. A run that hangs and gets killed by the 15-minute timeout produced a
log byte-for-byte the same shape as a green one, with no indication of where it
stopped -- four such timeouts since June were undiagnosable for this reason.

With STARTED and PASSED, the last STARTED without a matching PASSED is the test
that hung. Costs ~1160 extra log lines for ~580 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The job archived nothing, so `backend/build/test-results/test/*.xml` -- which
carries per-class start times and the captured stdout that `testLogging`
discards -- died with the runner on every failure.

An `if: always()` upload alone would not have fixed that: `timeout-minutes` sat
at *job* level, and a job timeout cancels the job, skipping all remaining steps.
So move the 15-minute limit onto the `Run tests` step and give the job a larger
cap; a step timeout fails only that step and lets the upload run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Knowing *which* test hung (previous commits) still leaves the question of where
inside it. A step timeout kills the JVM without a stack, so run a watchdog that
`jcmd Thread.print`s every live JVM at 11 and 13 minutes -- before the 15-minute
deadline -- and ship the dumps with the test-results artifact. Two dumps a couple
of minutes apart distinguish a genuinely stuck thread from a slow one.

The watchdog is killed as soon as Gradle returns, so it costs nothing on the
~99.3% of runs that finish normally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude claude Bot added the backend related to the loculus backend component label Aug 25, 2026
Comment thread .github/workflows/backend-tests.yml Outdated
Comment thread .github/workflows/backend-tests.yml Outdated
Comment thread .github/workflows/backend-tests.yml Outdated
Co-authored-by: Cornelius Roemer <cornelius.roemer@gmail.com>
Comment thread backend/build.gradle Outdated
corneliusroemer and others added 2 commits August 25, 2026 15:29
The earlier `**/*.xml` glob uploads nothing in the case it was written for.
Gradle converts the binary results to JUnit XML and HTML only when the `test`
task completes, so a killed run leaves neither. Measured by SIGKILLing a local
run: 0 XML files and no HTML report even though all 580 tests had executed --
only build/test-results/test/binary survived.

So upload that directory too, and add a `recoverTestReport` task that rebuilds a
report from it, run on failure.

Its limits are measured rather than assumed: recovery is complete when the tests
finished and the kill hit report generation, but yields an empty report when the
run is killed genuinely mid-flight -- 73 tests had passed across ~10 classes and
none appeared, because the binary store is not committed incrementally. For a
hang the STARTED/PASSED log and the thread dumps stay the usable evidence.

Also drop `retention-days: 14`, an arbitrary number that inherited the very
problem it was meant to solve; the repo default applies instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread .github/workflows/backend-tests.yml Outdated
corneliusroemer and others added 4 commits August 25, 2026 15:37
Inspecting the artifact from a green run showed 60 MB carrying the same
information three times: the JUnit XML (18 MB, and the only part that pays --
per-class timings plus the stdout logging Gradle discards from the console),
`binary/output-events.bin` (19 MB, that same stdout in Gradle's internal format),
and the HTML report (23 MB, a rendering of the XML).

Nobody debugs a passing run, so upload on failure only. Drop the HTML, which is
regenerable from the XML and absent on a kill anyway. Keep the binary results
only when Gradle produced no XML, which is exactly when it was killed and they
are the sole survivor; otherwise they duplicate the XML.

Reverses the `always()` upload added earlier: the "green baseline to diff a hang
against" it was justified by does not hold up -- that would want per-class
timings, not 60 MB of HTML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its 100k inserts were logged individually by Exposed at debug, so the JUnit XML
for that one class was 127 MB of the main-path artifact's 146 MB -- 123 MB of it
from Slf4jSqlDebugLogger. Compressed that is only ~6 MB, but it makes the
artifact unpleasant to download and expand.

Set the level through `logging.level.Exposed=WARN` on the test's Spring context
rather than manipulating the logger directly: a @BeforeAll that set the logback
level was measured to have no effect, because Spring Boot re-applies logging
configuration when the context starts, after @BeforeAll has run.

Batch test XML 127.37 MB -> 1.28 MB, whole test-results directory 146 MB -> 41 MB.

Checked that the level does not leak: logback levels are JVM-global, so this
could have silenced SQL logging for every later class. It doesn't -- the
DB-touching classes that run after this one still log Exposed at debug (the ones
that don't are unit tests that never touch the database).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@corneliusroemer
corneliusroemer marked this pull request as ready for review August 25, 2026 14:45
@corneliusroemer
corneliusroemer requested review from anna-parker, maverbiest, theosanderson and tombch and a lite review from Copilot August 25, 2026 14:45
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @corneliusroemer's task in 3m 29s —— View job


Review

Good change overall — the job/step timeout split is the right shape (a job timeout would skip the upload steps), the jcmd -l sweep correctly catches the daemon and the forked test workers, and the write-up of what Gradle actually persists on a kill is genuinely useful.

Two inline comments; only the first is worth acting on before merge:

  • Collect diagnostics can hang on the surviving Gradle daemon's project lock and burn the remaining job budget, which would skip the upload and lose the thread dumps. Suggested a timeout-minutes: 2.
  • watchdog cleanup doesn't kill the sleep — nit, currently harmless.

Checked and fine:

  • @EndpointTest(properties = [...]) is a real alias for @SpringBootTest.properties (EndpointTestExtension.kt:63), Exposed is the actual logger name (src/test/resources/logback-test.xml:12), and Spring Boot applies logging.level.* after loading logback-test.xml, so the override lands. @DirtiesContext is already on @EndpointTest, so the extra property costs no additional context startup.
  • recoverTestReport hardcoding test-results/test/binary rather than wiring test.binaryResultsDirectory is right — the provider form would add a task dependency and re-run test.
  • TestLogEvent.STARTED/PASSED adds ~1200 console lines; that's noise-free enough given it's the primary hang signal.

One general note: every new step is if: failure(), so the diagnostics path never executes on a green run — a typo in an artifact path or an unresolvable action ref would stay invisible until the next hang, when it's most expensive. If it wasn't already covered by the ~150 runs mentioned in the description, one deliberate forced-failure run would confirm the artifact actually lands (in particular that actions/upload-artifact@v7 resolves; I couldn't verify that tag from this environment, and nothing else in .github/workflows uses the action).

Also: RUN_EXTRA_TESTS is false on PRs, so the SubmitLargeBatchTest fix isn't exercised by this PR's own CI — it'll first run on the merge to main.
· branch ci/backend-test-hang-diagnostics

Comment thread .github/workflows/backend-tests.yml Outdated
Comment thread .github/workflows/backend-tests.yml

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves diagnosability of intermittent backend-tests CI hangs by increasing runtime test progress visibility and ensuring actionable artifacts (thread dumps + recovered reports where possible) are collected on failures/timeouts.

Changes:

  • Expand Gradle test task logging to include STARTED/PASSED events for progress visibility during hangs.
  • Add a recoverTestReport Gradle task and update the GitHub Actions workflow to collect binary test results, attempt report recovery, and upload diagnostics artifacts (including thread dumps) on failure.
  • Reduce overly-verbose Exposed logging for the 100k-row batch submission endpoint test to avoid excessively large test result outputs.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitLargeBatchTest.kt Overrides Exposed log level for this heavy test to prevent massive test result output.
backend/build.gradle Adds richer test logging and registers a report-recovery task for binary test results.
.github/workflows/backend-tests.yml Adds watchdog thread dumps, extends job timeout to allow artifact collection, and uploads diagnostics on failure.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread backend/build.gradle Outdated
The Gradle daemon survives the runner killing the test step's process tree, so
recoverTestReport can block on the project lock instead of failing, and `|| true`
does not help against a block. With a 15-minute test step inside a 20-minute job
there is only about three minutes left, so a block there would hit the job
timeout, which cancels the job and skips the upload - losing the thread dumps
this workflow exists to capture.
Cornelius Roemer added 2 commits August 25, 2026 15:29
Streaming STARTED/PASSED to the console made every run's log unreadable for the
sake of the rare hang that needs it. The progress is still what identifies a
hung test, so it now goes to build/diagnostics/test-progress.log, which the
existing artifact upload already picks up. Each line is appended and flushed as
it happens, so a killed run keeps everything up to the moment it died.

The lines are timestamped, which the console version could not be, so the log
also gives per-test durations and shows exactly when progress stopped.

Uses addTestListener rather than the beforeTest/afterTest closures: Gradle 9.7
deprecates those and removes them in 10, and they would have made the build
report as Gradle 10 incompatible.

On failure the diagnostics step prints just the tests that started and never
finished, so the one line worth having in the job log is still there without
the other 1159.
Anything logged from a plan-level hook has no test to be attributed to, so Gradle files it as root output of the whole test run, which exists only in build/reports/tests/test/index.html. That is where a failure to start the test environment reports its real cause.

Measured on a forced startup failure: 74 per-class XMLs were written and the underlying pg_ctl error appeared in none of them and in no console line, only in that one file.

This restores something earlier in this branch. 16bd0ce dropped reports/tests/** as a duplicate of the XML, which is true for a green or ordinarily-failing run and false for a startup failure, where the root page is the only durable copy of the cause. Rather than putting the whole report back, this takes just the root page: 57 KB against 9.4 MB, so the deduplication still holds for the per-class pages it was aimed at.

The general lesson, since the same reasoning will come up again: when pruning archived diagnostics, judge each copy by what it holds on the worst run, not the typical one.
Cornelius Roemer and others added 2 commits August 25, 2026 16:15
The listener exists to make a hang name the test it was stuck in, without
putting per-test output in the log of every normal run. Say that, and say why
it is a TestListener rather than the shorter deprecated closures.

Also say where the 127 MB came from, since the level alone doesn't show it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three simplifications and one reversal, all of which shrink the workflow:

`jcmd <substring>` matches every JVM whose main class contains the substring,
so `jcmd Gradle Thread.print` dumps the daemon and the test executor in one
call, replacing the pid loop. It no longer covers the Kotlin compile daemon,
but a wedge there shows up as the Gradle daemon waiting on it.

The suite runs one test at a time (no maxParallelForks, no forkEvery, no
junit-platform.properties), so the last line of the progress log is the test a
hang was stuck in and a tail replaces the started-but-never-finished set.

recoverTestReport is gone, along with the binary result store it read from.
Recovery is empty when a run is killed while tests are still running, which is
every hang we have, so it only paid off in the narrow window of a kill during
the report write.

Exposed logging stays at debug for the large-batch test: the 127 MB XML only
appears when that main-only test fails, and there is no level that keeps a
shorter trail, since Exposed inlines every parameter value into the statement
it logs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread backend/build.gradle Outdated
Comment thread backend/build.gradle Outdated
Comment thread .github/workflows/backend-tests.yml Outdated
Co-authored-by: Cornelius Roemer <cornelius.roemer@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend related to the loculus backend component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants