Skip to content

fix(engine): the API's daily run sweep deletes only finished runs, in batches, each through the run delete with an audit row - #353

Merged
bmscomp merged 11 commits into
mainfrom
fix/sweep-safe-audited
Oct 8, 2026
Merged

bmscomp merged 11 commits into
mainfrom
fix/sweep-safe-audited

Conversation

@bmscomp

@bmscomp bmscomp commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

#352 has merged (e0490ab), and main is merged in here (06f075f), so this PR's diff against main is its own work. 4665093 also stops linking book 12 to the DELETE /api/tests/{id} heading. The link checker reads that heading's trailing {id} as an attribute block, so the anchor doesn't exist for it.

Why

TestCleanupScheduler is the Kates API's own retention sweep: daily, kates.cleanup.retention-days, 90 by default. #352 found it unsafe:

  • it read every run with findAll(), results included, once a day;
  • it deleted any run that was not RUNNING or PENDING, so STOPPING runs too, whose tasks may still be producing;
  • it removed rows with repository.delete, which neither settles a run's tasks nor writes an audit row;
  • it swallowed a createdAt it could not parse;
  • one transaction held it all.

Changes

RunRetention (new, service/): the delete loop of #352's DELETE /api/tests, now shared.

  • count(statuses, before), and prune(statuses, before, limit, auditDetails), which returns Pass(matched, deleted, remaining).
  • prune counts, takes at most limit ids oldest first, deletes each through orchestrator.deleteTest(id), and counts again.
  • It writes an audit row only for a run it actually deleted. It isn't transactional, so each delete and each row commits on its own.
  • TestResource.pruneTests calls it. Its answers, 400s, audit details and log line are unchanged, and TestResourcePruneTest passes unmodified.

TestCleanupScheduler: same identity, property and 24 h schedule, and no more @Transactional around the whole sweep.

  • It sweeps only DONE and FAILED runs created before now minus the retention.
  • It works in passes of 1000, until none match, a pass deletes nothing, or 1000 passes.
  • Audit details are retention sweep: created before <cutoff> (kates.cleanup.retention-days=<n>).
  • Deletes are counted as they happen, so when a pass throws, the logged total still includes what that pass deleted. The error is logged at ERROR and ends that day's sweep, and nothing escapes the scheduled method.
  • PENDING, RUNNING and STOPPING runs stay, whatever their age.
  • No lease: if two replicas sweep at once, the second one's deleteTest finds a run gone and writes no row.
  • Each row names system:scheduler (Actor.SCHEDULER, from feat(api): every change names who made it in the audit log, over REST and gRPC #351). RunRetention.prune takes an Actor. The endpoint passes none, so its rows keep naming the caller and close the request's audit trail.

kates mcp: #351's audit-actor-recorded caveat now says the daily sweep leaves a system:scheduler row for each run it deletes, with anchored refs to where it records. (This branch's earlier edit to audit-no-actor went with that caveat when #351 retired it.) Refs into TestResource are re-pointed through a line map from #352's head, with a new anchored ref to RunRetention's audit row.

Docs: books 10, 11 and 12, and kates/docs/api-reference.md, describe the new sweep. Book 12 notes that each replica also sweeps when it starts. The chart README's sentence about the sweep was reworded in #352 (01a26b0) to stay true either way, so this PR touches nothing under charts/ and needs no chart bump.

Merge: #352's latest head (with main and #350) is merged in. The one conflict, book 11's DELETE /api/tests section, keeps both: #352's sentence on the admin scope, and this branch's description of the sweep.

Not done, filed separately: with STOPPING runs no longer swept, nothing ends a stale PENDING or STOPPING run. The reaper and orphan recovery read RUNNING only, and the reconciler reads only this process's handles. That needs a rule that can't fail a live run on another replica. It's filed as its own task ("Fail stale PENDING and STOPPING test runs").

Tests

  • New TestCleanupSchedulerTest (5 tests).
    • On the real database:
      • runs from 2001: DONE, FAILED, STOPPING, PENDING, one exactly at the cutoff, and newer ones;
      • with the batch size lowered to 2, sweep(2002-01-01) deletes exactly the five DONE and FAILED ones over several passes, each with one audit row;
      • a second sweep deletes nothing.
    • With RunRetention mocked:
      • a throwing pass keeps its count and throws nothing;
      • the scheduled cutoff is now minus the retention;
      • a pass that deletes 0 stops the sweep;
      • the sweep stops after 1000 passes.
    • Two deliberate breaks fail it, each reverted afterwards: letting STOPPING runs in again, and dropping the audit line.
  • TestResourcePruneTest (23) is unmodified and passes. TestResourceDeleteTest sets the new field.
  • The review had three reviewers (correctness, endpoint behaviour unchanged, docs against code), and a second reviewer checked each finding.
  • Run locally:
    • ./mvnw test -Dquarkus.grpc.codegen.skip=true -Dquarkus.http.test-port=0: 1,449 tests, 0 failures, before feat(api): every change names who made it in the audit log, over REST and gRPC #351. After the merge, a full run of 1,455 tests failed only 3 of TestCleanupSchedulerTest's mocked cases, which still expected the old audit call. Those were fixed, and the class (6 tests) passes with the prune, delete, audit and scope tests. spotless:check on JDK 25: clean.
    • cd cli && go test -p 1 ./... -race -count=1 and golangci-lint run --new-from-rev=origin/main ./...: 0 issues.
    • scripts/check-cli-style.sh, scripts/check-cli-compat.sh, scripts/check-book-style.sh and gen-version-matrix.sh --check.
    • python3 -m unittest in eval/mcp: OK.
  • Not run locally, because Docker is off: the *IT tests and the Kind tests.

…new DELETE /api/tests

The kates chart's cleanup CronJob, which values-prod.yaml and
values-staging.yaml enable, never deleted a run:
- it asked GET /api/tests for status COMPLETED, which the API refuses
  with a 400;
- it sent no API key, so got a 401 with auth on (the default);
- it paired the runs' id and createdAt in an order the JSON never has;
- it computed its cutoff with date options busybox lacks;
- it read only the newest 200 runs;
- it carried the API pod's labels, so the chart's NetworkPolicy blocked
  it from the API.

The API now does the picking: DELETE /api/tests?createdBefore=<instant>
deletes the finished runs (DONE and FAILED by default, ?status= to
narrow) created before then. It deletes oldest first, at most limit
(1000) a call, each through the same delete as DELETE /api/tests/{id}
and with an audit row, and answers matched, deleted and remaining.
?dryRun=true only counts. Anything else is a 400, a malformed dryRun
included, so a typo cannot turn a count into a delete.

The CronJob sends the key from the apiKey Secret and computes its
cutoff with date -d @seconds, which busybox reads. It calls until no run
matches, at most 100 calls, and fails on anything but a 200 with the
API's answer; backoffLimit is 2. cleanup.statuses (default DONE and
FAILED) picks the runs. Chart 0.10.10.

kates test prune --older-than 30d does the same by hand. It counts first,
asks unless --yes, and with --dry-run only counts. prune is no longer an
alias of kates test cleanup, which deletes orphans still RUNNING.

Caveat refs into TestResource and TestRunRepository are re-pointed.
…ention

#350 made every REST endpoint name its scope (deny-unannotated-
endpoints), so DELETE /api/tests takes @RolesAllowed(admin), as the
single and bulk deletes do; ScopesTest now refuses an agent's prune too,
and book 11 lists pruning under admin. TestResource's only conflict was
its imports: both kept. The caveat refs take main's copy of each
cli/cmd/mcp file, re-pointed once through a line map from main's
TestResource and TestRunRepository to this branch's.
…uses it is about to change

The sentence named the runs the sweep leaves alone, PENDING and RUNNING,
which the follow-up that makes the sweep delete only DONE and FAILED runs
changes. Saying it deletes old finished runs stays true either way, so
that PR needn't touch charts/ and bump the chart again.
… batches, each through the run delete with an audit row

TestCleanupScheduler, the Kates API's own retention sweep
(kates.cleanup.retention-days, 90 days):
- read every run with findAll(), results included, each day;
- deleted any run that was not RUNNING or PENDING, so STOPPING runs too,
  whose tasks may still produce;
- removed rows with repository.delete, so it neither settled a run's
  tasks nor wrote an audit row;
- swallowed a createdAt it could not parse;
- held one transaction around it all.

The delete loop of DELETE /api/tests moves into RunRetention:
- count, then at most limit ids oldest first;
- each through orchestrator.deleteTest with an audit row;
- count again.
The endpoint answers byte for byte as before.

The sweep uses that loop:
- DONE and FAILED runs older than the retention only, in passes of 1000,
  until none match, a pass deletes nothing, or 1000 passes;
- audit details "retention sweep: created before <cutoff>
  (kates.cleanup.retention-days=<n>)";
- deletes count as they happen, so a pass that throws still reports
  what it deleted. The error is logged and ends that sweep.
PENDING, RUNNING and STOPPING runs stay, whatever their age.
…to fix/sweep-safe-audited

The one conflict, book 11's DELETE /api/tests section, keeps both sides:
#352's sentence on the admin scope, and this branch's description of the
new sweep in place of the old one.
…ly sweep, and refs follow the moved lines

The audit-no-actor caveat and the did_kates_cause_this prompt told
agents that only the REST test endpoints write audit rows. DELETE
/api/tests (#352) and the API's daily retention sweep now write them
too. Caveat refs into TestResource are re-pointed through a line map
from #352's head, with an anchored ref to RunRetention's audit row.
…ention

#351 holds every write to @Audited or @NotAudited, so DELETE /api/tests
is @Audited(DELETE, test). Each run it deletes still gets its own row,
which now names who pruned it; a call that deletes none, or is refused,
gets the response filter's row, and a dry run marks its trail done and
leaves none. AuditActorTest checks an agent's refused prune;
TestResourcePruneTest uses list's actor argument; book 11 says who the
rows name. The caveat refs take main's copy of each cli/cmd/mcp file,
re-pointed once from main's TestResource and TestRunRepository.
…ix/sweep-safe-audited

#351 is on main, so the sweep's audit rows name system:scheduler:
RunRetention's prune takes an Actor, the sweep passes Actor.SCHEDULER,
and the endpoint passes none, so its rows keep naming the caller and
mark its audit trail done. TestCleanupSchedulerTest checks the rows by
actor and the mocks follow the new argument.

Conflicts:
- TestResource: both injections kept (RunRetention, AuditTrail); a dry
  run marks its trail done, then counts through RunRetention.
- Book 11: #352's sentence on who the rows name, and this branch's
  description of the sweep, now naming system:scheduler, as books 12
  and kates/docs do.
- The cli/cmd/mcp files take #352's copy, with #351's audit texts,
  re-pointed once to this branch's Java. audit-no-actor is retired on
  main, so this branch's edit to it goes; audit-actor-recorded names
  the sweep instead, with anchored refs to where it records.
#352's squash has the same tree as its last head, 31785d1, which this
branch already had (7e6da8c), so every conflict resolves to this
branch's side: TestResource, TestRunRepository, the test
application.properties, kates mcp's runs and scenario tools, books 10
and 11, and kates/docs/api-reference.md. Book 12 merged without a
conflict but with #352's cleanup paragraphs appended a second time,
the last one with the old sweep's "not RUNNING or PENDING"; it keeps
this branch's text too. The merge's tree is 7e6da8c's.
bmscomp added a commit that referenced this pull request Oct 8, 2026
… code, not as a link

Markdown Link Check failed on 12-deployment.md:80 with "Cannot find
fragment" for 11-api-reference.md#delete-apitestsid. lychee reads the
trailing {id} of "#### DELETE /api/tests/{id}" as a Pandoc attribute
block, so it gives that heading no such anchor, although Quarto renders
one. The link came with #353's sweep paragraph, and #353 fails the same
check. Other chapters name such endpoints in code instead.
…eading

Markdown Link Check failed on #delete-apitestsid. lychee's parser reads
a trailing {id} on a heading as an attribute block, so to it
"DELETE /api/tests/{id}" is the heading "DELETE /api/tests" and has no
such anchor, while Quarto reads {id} as text. No anchor satisfies both
checkers, so the two mentions are plain code.
@bmscomp
bmscomp merged commit 44b880a into main Oct 8, 2026
58 checks passed
bmscomp added a commit that referenced this pull request Oct 8, 2026
…their deadline, so no run stays unended for good (#354)

* fix(chart): the test-run cleanup CronJob deletes old runs, through a new DELETE /api/tests

The kates chart's cleanup CronJob, which values-prod.yaml and
values-staging.yaml enable, never deleted a run:
- it asked GET /api/tests for status COMPLETED, which the API refuses
  with a 400;
- it sent no API key, so got a 401 with auth on (the default);
- it paired the runs' id and createdAt in an order the JSON never has;
- it computed its cutoff with date options busybox lacks;
- it read only the newest 200 runs;
- it carried the API pod's labels, so the chart's NetworkPolicy blocked
  it from the API.

The API now does the picking: DELETE /api/tests?createdBefore=<instant>
deletes the finished runs (DONE and FAILED by default, ?status= to
narrow) created before then. It deletes oldest first, at most limit
(1000) a call, each through the same delete as DELETE /api/tests/{id}
and with an audit row, and answers matched, deleted and remaining.
?dryRun=true only counts. Anything else is a 400, a malformed dryRun
included, so a typo cannot turn a count into a delete.

The CronJob sends the key from the apiKey Secret and computes its
cutoff with date -d @seconds, which busybox reads. It calls until no run
matches, at most 100 calls, and fails on anything but a 200 with the
API's answer; backoffLimit is 2. cleanup.statuses (default DONE and
FAILED) picks the runs. Chart 0.10.10.

kates test prune --older-than 30d does the same by hand. It counts first,
asks unless --yes, and with --dry-run only counts. prune is no longer an
alias of kates test cleanup, which deletes orphans still RUNNING.

Caveat refs into TestResource and TestRunRepository are re-pointed.

* fix(chart): the README describes the API's own sweep without the statuses it is about to change

The sentence named the runs the sweep leaves alone, PENDING and RUNNING,
which the follow-up that makes the sweep delete only DONE and FAILED runs
changes. Saying it deletes old finished runs stays true either way, so
that PR needn't touch charts/ and bump the chart again.

* fix(engine): the API's daily run sweep deletes only finished runs, in batches, each through the run delete with an audit row

TestCleanupScheduler, the Kates API's own retention sweep
(kates.cleanup.retention-days, 90 days):
- read every run with findAll(), results included, each day;
- deleted any run that was not RUNNING or PENDING, so STOPPING runs too,
  whose tasks may still produce;
- removed rows with repository.delete, so it neither settled a run's
  tasks nor wrote an audit row;
- swallowed a createdAt it could not parse;
- held one transaction around it all.

The delete loop of DELETE /api/tests moves into RunRetention:
- count, then at most limit ids oldest first;
- each through orchestrator.deleteTest with an audit row;
- count again.
The endpoint answers byte for byte as before.

The sweep uses that loop:
- DONE and FAILED runs older than the retention only, in passes of 1000,
  until none match, a pass deletes nothing, or 1000 passes;
- audit details "retention sweep: created before <cutoff>
  (kates.cleanup.retention-days=<n>)";
- deletes count as they happen, so a pass that throws still reports
  what it deleted. The error is logged and ends that sweep.
PENDING, RUNNING and STOPPING runs stay, whatever their age.

* fix(cli): kates mcp's audit texts name the prune endpoint and the daily sweep, and refs follow the moved lines

The audit-no-actor caveat and the did_kates_cause_this prompt told
agents that only the REST test endpoints write audit rows. DELETE
/api/tests (#352) and the API's daily retention sweep now write them
too. Caveat refs into TestResource are re-pointed through a line map
from #352's head, with an anchored ref to RunRetention's audit row.

* fix(engine): the timeout reaper fails PENDING and STOPPING runs past their deadline, so no run stays unended for good

Since #353 the daily sweep deletes only DONE and FAILED runs, and
nothing ended some runs at all. A run stays PENDING when its submission
never stores it RUNNING, as when the Kates API stops while it creates
the run's topic, and STOPPING when a Kates API before 1.25.0, which
stored that status on the way to ending a run, stopped before it did.
No later version stores it. The reaper and
orphan recovery read RUNNING runs only, and the reconciler only this
process's handles, so such a run stayed as it was, counted toward
nothing, and no sweep removed it.

The reaper now holds PENDING and STOPPING runs to the deadline it holds
RUNNING ones to: the planned duration, capped by
kates.engine.max-duration-ms, plus kates.engine.reaper-grace-ms, counted
from creation. Each unfinished task gets an error that starts
"Timeout: still pending" or "Timeout: still stopping" and says why. A
run with no task, as a PENDING run has none, gets one,
<id>-submission, that carries the error, so the API and a JUnit export
say why it failed.

The deadline is the only rule that can't fail a live run on another
replica. Which process runs a run is not stored: handles and permits
are in memory, and a PENDING run has no handle even in its own process,
since handles are published after the RUNNING write. recoverOrphans
doesn't tell runs apart either; it fails every RUNNING run, which is
why the chart pins one replica. A "no handle here, older than a
startup grace" rule would fail another replica's submission still
under way, so it was left out. The deadline is stored with the run and
bounds its whole life, so failing a PENDING or STOPPING run past it
ends nothing sooner than the RUNNING reaper would.

A PENDING run is settled only after its conditional write lands. Its
submission in this process may store it RUNNING in between, and
settling first would hand back the permit of a run that goes on.
RUNNING and STOPPING runs are still settled first.

The reaper-deadline caveat in kates mcp says this, and its ref to
TestTimeoutReaper is re-pointed and anchored. The audit-actor ref to
TestCleanupScheduler follows its javadoc, now two lines shorter.
Books 02, 11, 12 and the glossary describe the new rule.

* docs(book): book 12's sweep paragraph names DELETE /api/tests/{id} in code, not as a link

Markdown Link Check failed on 12-deployment.md:80 with "Cannot find
fragment" for 11-api-reference.md#delete-apitestsid. lychee reads the
trailing {id} of "#### DELETE /api/tests/{id}" as a Pandoc attribute
block, so it gives that heading no such anchor, although Quarto renders
one. The link came with #353's sweep paragraph, and #353 fails the same
check. Other chapters name such endpoints in code instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant