Repository navigation
fix(engine): a status poll writes only over the status it read, so a cancelled run stays cancelled - #377
Merged
Conversation
…cancelled run stays cancelled The reconciler, or a GET, reads a RUNNING run and polls its tasks. A cancel that lands during that poll settles the run, stops its workers, frees its permit and stores it FAILED. The poll then wrote its own copy over that FAILED with saveIfPresent. If the stopped tasks still answered RUNNING, the row went back to RUNNING with no handles and no permit, nothing polled it again, and only the timeout reaper ended it, up to 2 h 5 min later. If they answered DONE, a cancelled run was stored DONE, read as passed, and was announced ended twice. The same write could land on the timeout reaper's FAILED too, and a poll that read a run PENDING could write over the submission's fresh RUNNING. refreshStatus now writes with saveIfStatus over the status it read. When that write loses, it leaves the row alone, settles only when the row is gone, and answers the run as this request reads it: the stored row from the reconciler, while a GET that lost to a cancel can answer the run it read before the cancel once, as its request session keeps that copy. Its ending (dropping the handles, the DONE or FAILED event, the completion metrics, ending the run's meters and freeing its permit) now comes after the write lands, so a run another writer ended is not announced or counted again, and a run that only moved PENDING to RUNNING keeps the handles its next poll needs. saveIfPresent has no caller left and is removed; saveIfStatus's javadoc takes over its never-inserts guarantee, and the test stubs and repository tests move to saveIfStatus. TestOrchestratorCancelTest covers a cancel during a poll whose stopped tasks answer RUNNING and DONE, and a poll that read the run PENDING and loses to the submission's RUNNING: the run keeps its handles, nothing announces it ended, and its next poll ends it. The cli/cmd refs past refreshStatus are re-pointed by +16, and TestRunRepository.java's findWithResults ref by -19.
This was referenced Oct 17, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The reconciler, or a
GET /api/tests/{id}, reads a RUNNING run, andrefreshStatuspolls each of its tasks. A cancel can land during that poll.cancelTestsettles the run first: it drops the handles, stops the workers and frees the permit. Then it stores the run FAILED withsaveIfStatus(cancelled, RUNNING). Next the poll wrote its own copy withsaveIfPresent(run). That method checks only that the row exists, so the poll's copy replaced the cancel's FAILED:kates test getand--waitshow RUNNING until the timeout reaper ends the run at its deadline. By default that is up to 2 h 5 min after the run was created.recordTestCompleteda second time.The window is one poll. On Trogdor each task poll is an HTTP call, so the window grows with the number of tasks.
TestOrchestratorCancelTesthad no case for this.The code shows two more writes the poll could overwrite:
saveIfStatus;executeAsync'ssaveIfStatus(run, PENDING).The finding's fix was incomplete in two ways:
saveIfPresenthas no caller left in main, but five test stubs and threeTestRunRepositoryTesttests still call it, and three javadoc links name it.Changes
TestOrchestrator.refreshStatusThe poll writes with
repository.saveIfStatus(run, priorStatus), so it writes only over the status it read. The write now comes before the ending block, not after it.When the write loses, the poll reads the run back with
findById:The ending block now runs only after this poll's write lands, under the same
allDone && !updatedResults.isEmpty()test as before. The block covers:recordTestCompleted;benchmarkMetrics.endRun;releasePermit.Because of this, two polls that read the same RUNNING run can no longer both count its completion: the second one's write loses.
The
OptimisticLockExceptioncatch is unchanged.deleteTest's javadoc now linkssaveIfStatusinstead ofsaveIfPresent. The line count is the same.TestRunRepositorysaveIfPresentand its javadoc are removed.saveIfStatus's javadoc takes over the guaranteesaveIfPresentdocumented: it only updates and never inserts, so a write that read the run before a delete cannot bring the run back.{@link #delete}takes the same row lock. Its@returnnow also says false when the row is gone. The method already behaved that way.delete()'s javadoc names onlysaveIfStatus's lock.Test stubs
saveIfPresentstubs are gone fromInMemoryEngine,TestOrchestratorCancelTest,TestOrchestratorLiveMetricsTest,TestOrchestratorTestandTestTimeoutReaperSubmissionTest. TheirsaveIfStatusstubs already compare statuses, so the polls in those tests still store their writes. InTestTimeoutReaperSubmissionTest, only the reaper's write arms thebeforeConditionalWritehooks, so poll writes do not trigger them.InMemoryEngine's class javadoc and the comment onTestOrchestratorLiveMetricsTest'ssaveIfStatusstub now say that polls write throughsaveIfStatustoo.TestOrchestratorCancelTest.RecordingBackendgets three fields:onPoll, which runs first in each poll;stoppedAnswer, the status a stopped task answers with (FAILED by default, as before);liveAnswer, the status a task that was not stopped answers with (RUNNING by default, as before).kates mcprefs (no caveat text changes)refreshStatusis 16 lines longer, so everyTestOrchestrator.javaref past it moves by +16:mcp_caveats.go: :70, :92 (only its ranges past line 698), :115, :116 and :159;mcp_tools_runs.go: the anchored refs at :2233 (second range only), :2270, :2303 and :2306, and the comment refs at :187, :662 and :788;mcp_tools_scenario.go: :1247 and :1263 (:1263 stays identical tomcp_caveats.go:115);mcp_tools_runs_test.go: the comment ref at :815.TestRunRepository.javais 19 lines shorter abovefindWithResults, somcp_caveats.go:171moves from 354-374 to 335-355.cancel-stored-as-failedis still true as written.Tests
Added:
TestOrchestratorCancelTest.aCancelDuringAPollStaysTheRunsEnding, run twice: with the stopped tasks answering RUNNING, and with them answering DONE.executeAsyncsubmits the run. The poll's first task poll callscancelTest, and thenrefreshStatusreturns.recordTestCompleted("LOAD", "failed")runs once and("LOAD", "done")never runs. A laterreconcileActiveRunsleaves the row alone, and every slot is free.TestOrchestratorCancelTest.aPollThatLosesToTheSubmissionKeepsTheRunGoing. This test pins the ordering fix described under Why.endRunis not called. The nextreconcileActiveRunsends the run DONE and announces it once.TestRunRepositoryTest, on a real database:saveIfPresentUpdatesAStoredRunbecomessaveIfStatusWritesOnlyOverTheStatusItExpects. A write from PENDING to RUNNING lands. A second write that expects PENDING does not, and RUNNING stays stored.saveIfPresentNeverInsertsARunbecomessaveIfStatusNeverInsertsARun. The write returns false after a delete, and also for a run that was never stored.anIntegrityResultIsStoredWithItsTasknow writes withsaveIfStatus(..., RUNNING), thensaveIfStatus(..., DONE).Run locally, all passing:
cd kates && ./mvnw -o test -Dquarkus.grpc.codegen.skip=true -Dquarkus.http.test-port=0: 1,469 tests, 0 failures, 0 errors, 0 skipped. The one ERROR line in the log is expected:OutboxCleanerTestexercises dead-letter handling on purpose.cd kates && JAVA_HOME=$HOME/.sdkman/candidates/java/25.0.2-graalce ./mvnw -o spotless:check -Dquarkus.grpc.codegen.skip=true: 399 files clean.cd cli && go test ./...: every package ok. This includesTestMCPCaveatRefAnchorsincmd, so the re-pointed refs are anchored.cd cli && go test -p 1 -race -count=1 ./cmd/: run again with CI's flags on the changed package; ok in 103 s.cd cli && go vet ./...: no output.cd cli && golangci-lint run --new-from-rev=origin/main ./...: 0 issues.cd cli && govulncheck ./...: no vulnerabilities.scripts/check-cli-style.shandscripts/check-cli-compat.sh: OK.cli/cmd/(4 files) andkates/src/{main,test}(8 files).Run only in CI:
ci.ymlJava job,./mvnw verify: the*ITfailsafe tests need Docker/Testcontainers (EngineLifecycleITruns on Postgres), and thejacoco:checkcoverage floor is bound toverify.ci.ymlGo job: the fullgo test -p 1 -race -count=1with coverage across everyclipackage. Locally onlycli/cmdran with-race.native.yml: the native image build and smoke test, triggered bykates/**.integration.yml: the Kind end-to-end tests.ci-docker.yml: the Docker image builds.ci.yml: the dependency CVE scan, which needs network.codeql.yml: CodeQL.github-advanced-securityAI scan, which fails on every PR on GitHub's side.Decisions
OptimisticLockExceptioncatch.EntityMappercopies no version andsaveIfStatusholds the row lock, so the exception should not fire. Removing the catch would be churn.findByIdafter a lost compare-and-set, ascancelTestdoes, not inQuarkusTransaction.requiringNew().activeHandles.remove,endRunandreleasePermitafter the write, together with the event and the metrics (the ordering fix under Why).Deliberately not done
findByIdafter a lost write may return the request session's copy. A GET that lost to a cancel may then answer the pre-cancel status once. The stored row is right, and the next GET reads it fresh, the same as the existing re-reads inrefreshStatusandcancelTest.mcp_tools_runs.go:662is still stale. On main, this comment ref already cited lines that do not hold the<type>-testtopic default (it cited 1598 and 1925-1926; the default was at 1612 and 1939-1940). This PR moves it by +16, like every other ref, to 1614 and 1941-1942. Here the right lines are 1628 and 1955-1956. It is a comment ref, so no test catches it. It is left for a follow-up.TestRunRepository.delete()'s javadoc keeps its line count, so after the reflow its third line is short.docs/backend-enhancement-plan*.md. These files citeTestOrchestrator.javaandTestRunRepository.javalines that were already stale on main. They are not touched.mcp_caveats.go:171(TestRunRepository.java,findWithResults). Whichever lands second must recompute the ref from the merged file: 335 here, plus the lines H adds.Landing
Lands after #375 (plan PR 6) and #376 (plan PR 7), because both edit files this PR also edits.
cli/cmd/mcp_tools_runs.goandcli/cmd/mcp_tools_scenario.go. The hunks are disjoint:mcp_tools_runs.go: fix(chaos): compound runs and resilience faults go through the safety guard and the disruption lease #375 edits :2395 and :2397; this PR edits :187, :662, :788, :2233, :2270, :2303 and :2306.mcp_tools_scenario.go: fix(chaos): compound runs and resilience faults go through the safety guard and the disruption lease #375 edits :1289; this PR edits :1247 and :1263.cli/cmd/mcp_caveats.go,cli/cmd/mcp_tools_runs.go,cli/cmd/mcp_tools_scenario.go,TestOrchestrator.javaandTestOrchestratorTest.java.mcp_caveats.go, which sits two lines lower after fix(engine): each INTEGRITY run reads in a group of its own, and a consumer with no committed offset starts at its task's first record, not the topic's oldest #376's insert at :105. This PR changes the range on theTestOrchestrator.java:1619-1623,1714-1718line, and fix(engine): each INTEGRITY run reads in a group of its own, and a consumer with no committed offset starts at its task's first record, not the topic's oldest #376 changes the anchor on the next line plus theNativeKafkaBackend.javaref. To resolve it, take this PR's ranges (1635-1639,1730-1734) and fix(engine): each INTEGRITY run reads in a group of its own, and a consumer with no committed offset starts at its task's first record, not the topic's oldest #376's anchor andNativeKafkaBackendline.TestOrchestrator.java:1715-1718 in place without changing the line count, so the +16 still holds.Recipe once both have merged:
TestOrchestrator.javaandTestRunRepository.javafrom this branch's base to the merged files.mcp_caveats.go:92and the comment refs).cd cli && go test ./cmd -run TestMCPCaveat.Main has also gained #370 since this branch's base. It touches only
Makefileanddocs/kates-chaos-chart.md, so it does not overlap this PR.