Skip to content

feat(github): pace content-creating GitHub calls so a burst is never produced - #597

Open
devops-thiago wants to merge 2 commits into
release/v0.6.0from
fix/579-write-pacer
Open

feat(github): pace content-creating GitHub calls so a burst is never produced#597
devops-thiago wants to merge 2 commits into
release/v0.6.0from
fix/579-write-pacer

Conversation

@devops-thiago

@devops-thiago devops-thiago commented Aug 12, 2026

Copy link
Copy Markdown
Owner

What type of PR is this?

  • ✨ Feature

Description

#568 made a throttled post survivable; it does not stop the burst being produced. In the run behind
it, 56 commands across 8 PRs created content faster than GitHub would accept it and drew 29
rejections
— every one a wasted round trip, a spent retry attempt, and a dispatcher slot held
while a doomed call backed off.

GitHubWritePacer spaces content-creating calls out instead, so the bot stays inside GitHub's
secondary-rate-limit envelope rather than discovering it by rejection.

Where it sits. On the same seam as the backoff — GitHubWriteRetry#call — which is exactly the
set GitHub counts as content creation: createComment, updateComment, createReview,
createPullRequestComment, replyToReviewComment. It is therefore process-wide and shared across
reviews and commands rather than per review, which is what the issue asks for: the bot produces a
burst without anyone doing anything unusual. ReviewPublisher#tryPostInlineComment issues one
content-creating call per inline finding, and several PRs are reviewed concurrently.

How a slot is claimed. Each caller atomically claims the next free instant and advances a shared
cursor by one interval, then waits until the instant it claimed. Claiming is a single atomic update
with no lock held across the HTTP call, so a slow request never blocks the queue behind it, and
callers go out in the order they arrived instead of in a thundering herd when the interval elapses.

Both numbers are knobs rather than constants, as the issue asks:

key env default meaning
thrillhousebot.github.write-min-interval GITHUB_WRITE_MIN_INTERVAL 1s spacing between two content-creating requests; 0 disables pacing entirely
thrillhousebot.github.write-max-wait GITHUB_WRITE_MAX_WAIT 60s ceiling on how long one caller waits for its slot

The default is GitHub's published guidance: no more than one content-creating request per second.

Why there is a ceiling. A wait holds the per-PR serialization slot in the dispatcher — the same
reason #568's backoff is bounded twice over. Past the ceiling the call goes out unpaced and the
bounded backoff handles a refusal, which is exactly where the bot is today, rather than parking a
finished command for minutes.

Why pacing can never cost a payload. A pacing wait that is interrupted proceeds with the call
rather than failing it. The content on its way out has already been paid for, so the worst this
limiter is ever allowed to do is let a burst through.

How this composes with #568 and #578. The pacer is the preventative form: it keeps the burst
from being produced. The backoff remains the fallback for throttling pacing cannot prevent — another
instance of the App, or a repo busy for reasons the bot did not cause — and #578's dropped-post
notice is the last resort when even that runs out. Every attempt is paced, repeats included, so a
repeat queues rather than jumping the limiter.

Related Issues

Fixes #579

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

GitHubWritePacerTest drives the clock by hand and records the waiting instead of serving it, so
what is pinned is the arithmetic of the shared cursor: the lone write that is not delayed at all, a
burst handed out one interval apart, a queue that drained charging nothing, the ceiling clamping a
long queue, 0 disabling pacing, an interrupted wait sending the call anyway, and the knob falling
back to the guidance when unset. GitHubWriteRetryTest gains a case showing a throttle repeat is
paced too.

GitHubWritePacingTest is a @QuarkusTest driving a real REST client against a loopback GitHub —
the only thing that can show the limiter is on the path a comment actually takes, and that it holds
when the burst is genuinely concurrent. It asserts on when the requests arrive at GitHub, not on
anything the bot recorded about itself.

Red/green proof

GitHubWritePacingTest compiles unchanged against the base commit, so it was run there:

[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 3.075 s <<< FAILURE! -- in dev.thiagogonzaga.thrillhousebot.github.GitHubWritePacingTest
[ERROR] dev.thiagogonzaga.thrillhousebot.github.GitHubWritePacingTest.aConcurrentBurstOfCommentsReachesGitHubSpacedOutRatherThanAllAtOnce -- Time elapsed: 0.586 s <<< FAILURE!
org.opentest4j.AssertionFailedError: content-creating calls reached GitHub [0, 0, 0]ms apart; its envelope is one per second, and a burst tighter than that is exactly what it answers with 403 ==> expected: <true> but was: <false>
	at org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:199)
	at dev.thiagogonzaga.thrillhousebot.github.GitHubWritePacingTest.aConcurrentBurstOfCommentsReachesGitHubSpacedOutRatherThanAllAtOnce(GitHubWritePacingTest.java:128)

[0, 0, 0]ms apart is the defect verbatim: four concurrent posts all reach GitHub inside the same
millisecond, which is the burst it answers with 403. With the fix they arrive a second apart.

Gates

  • ./mvnw -B spotless:apply → BUILD SUCCESS
  • ./mvnw -B clean compile spotbugs:check spotless:checkBugInstance size is 0, BUILD SUCCESS
  • ./mvnw -B clean testTests run: 2779, Failures: 0, Errors: 0, Skipped: 0
  • JaCoCo ∩ git diff -U0 fda4bc7...HEAD0 uncovered lines, 0 uncovered branches across the 38 trackable changed main-code lines (GitHubWritePacer 32, GitHubWriteRetry 6)

Checklist

  • My code follows the project's coding standards
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly
  • My changes generate no new warnings or errors

Screenshots / Logs

The limiter is quiet by design — it logs at debug, because a slot wait is normal operation rather
than an incident:

DEBUG [GitHubWritePacer] Pacing a comment on owner/repo #3 — waiting 1000ms for a content-creation slot

The line that used to appear instead is the 403 the wait prevents.

Additional Notes

ThrillhouseConfig.GitHubConfig gains the two knobs. That is not optional: @ConfigMapping(prefix = "thrillhousebot") validates its whole namespace, so an undeclared thrillhousebot.github.* key
fails startup outright —

io.smallrye.config.ConfigValidationException: Configuration validation failed:
	SRCFG00050: thrillhousebot.github.write-min-interval ... does not map to any root

— and the declaration is the namespace's schema. The limiter itself reads the key directly, because
it lives on the REST clients' default-method write path where there is no CDI.

The follow-up for #578 (announcing a dropped post on the PR) touches a disjoint set of files —
GitHubCommentClient, GitHubReviewClient and a new class — so the two merge cleanly in either
order.

Review follow-up

Both of the review's lower-confidence findings were checked against the code rather than argued
with. Both turned out to be right about the evidence being thin and wrong about there being a
defect, so nothing under src/main changed and two tests were added instead.

"Verify all five content-creating methods dispatch through GitHubWriteRetry.call." They do —
all five, and nothing else does:

$ grep -n "GitHubWriteRetry.DEFAULT.call" src/main/java/dev/thiagogonzaga/thrillhousebot/github/*.java
GitHubCommentClient.java:58    createComment
GitHubCommentClient.java:136   updateComment
GitHubReviewClient.java:56     createReview
GitHubReviewClient.java:172    createPullRequestComment
GitHubReviewClient.java:202    replyToReviewComment

Five hits, five methods, no other call sites — and call paces unconditionally, so reaching it is
being paced. The finding is right that only one of the five was demonstrated end to end, and that is
worth closing because it is this PR's central claim. GitHubWritePacingTest.everyContentCreatingCallIsPacedAndNotJustTheConversationComment
now fires all five at once against the loopback GitHub and asserts every arrival is spaced. On the
base commit it fails exactly as the finding predicts an unpaced path would:

org.opentest4j.AssertionFailedError: content-creating calls reached GitHub [1, 2, 0, 0]ms apart; its envelope is one per second, and a burst tighter than that is exactly what it answers with 403 ==> expected: <true> but was: <false>
	at dev.thiagogonzaga.thrillhousebot.github.GitHubWritePacingTest.everyContentCreatingCallIsPacedAndNotJustTheConversationComment(GitHubWritePacingTest.java:199)

"Documented '0 disables pacing' value never exercised through the config converter." It survives
it. Measured against the real SmallRye converter rather than reasoned about:

PROBE raw=0    -> PT0S
PROBE raw=0s   -> PT0S
PROBE raw=0S   -> PT0S
PROBE raw=PT0S -> PT0S

So a bare 0 converts to Duration.ZERO, the documentation is accurate, and the startup-validation
failure the finding feared does not occur — no wording changed. The gap it identifies is real
though: that promise runs through the converter, while the only zero-interval test handed
Duration.ZERO straight to the constructor. GitHubWritePacerTest.theDocumentedBareZeroSurvivesTheConfigConverterAndReallyDisablesPacing
now sets the property, converts it, and paces a burst with the result, because the failure mode
would otherwise stay invisible until an operator reached for the knob during an incident.

…produced

Backoff (#568) is reactive: it only acts once GitHub has already refused a
post. In the run behind that issue, 56 commands across 8 PRs created content
faster than GitHub would accept it and drew 29 rejections — each a wasted
round trip, a spent retry attempt and a dispatcher slot held while a doomed
call backed off.

GitHubWritePacer spaces content-creating calls out instead. It sits on the
same seam as the backoff, so it covers exactly the calls GitHub counts as
content creation, and it is process-wide: a review posting one call per
inline finding, or several PRs reviewed at once, both produce a burst
without anyone doing anything unusual.

Each caller atomically claims the next free instant and waits for it, with
no lock held across the HTTP call. The spacing (default 1s, GitHub's
published guidance) and the ceiling on a single wait (default 60s) are
knobs. Past the ceiling the call goes out unpaced and the bounded backoff
handles a refusal, so a long queue never parks a finished command. An
interrupted pacing wait sends the call anyway: the limiter must never be
the reason a paid-for post is lost.

Fixes #579
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot PR Summary

What this PR does

Adds a process-wide GitHubWritePacer that spaces content-creating GitHub calls (comments, reviews, review comments) via an atomic slot cursor claimed in GitHubWriteRetry.call, so bursts are spaced out before GitHub refuses them instead of being discovered by 403s. Both the min interval (default 1s, 0 disables) and the max wait (default 60s) are configurable knobs, every retry attempt is paced, and an interrupted pacing wait still lets the call proceed.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
    A["Write path: createComment, createReview, etc."] --> B["GitHubWriteRetry.call(operation)"]
    B --> C["pacer.acquire(operation)"]
    C --> D{"slot already past?"}
    D -->|"yes"| E["call goes out immediately"]
    D -->|"no"| F{"wait exceeds max-wait?"}
    F -->|"no"| G["sleep until claimed slot"]
    F -->|"yes"| H["sleep max-wait, then go unpaced"]
    E --> I["operationCall.get() posts to GitHub"]
    G --> I
    H --> I
    I --> J{"throttled 403/429?"}
    J -->|"no"| K["return result"]
    J -->|"yes"| L["bounded backoff sleep"]
    L --> B
Loading

Changes Overview

  • Files changed: 7
  • Lines added: +506
  • Lines removed: -2

Changed Files

File Change Summary
src/main/java/dev/thiagogonzaga/thrillhousebot/config/ThrillhouseConfig.java Modified Declares write-min-interval and write-max-wait Duration knobs in the GitHubConfig mapping namespace.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWritePacer.java Added New process-wide pacer: atomic slot cursor, configurable interval/max-wait, interrupt-safe acquire.
src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java Modified Every attempt (first included) acquires a pacing slot before the call; 2-arg constructor keeps tests unpaced.
src/main/resources/application.properties Modified Documents and defaults GITHUB_WRITE_MIN_INTERVAL (1s) and GITHUB_WRITE_MAX_WAIT (60s).
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWritePacerTest.java Added Unit tests pinning cursor arithmetic with a hand-driven clock and recorded instead of served waits.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWritePacingTest.java Added QuarkusTest proving four concurrent createComment posts arrive at loopback GitHub at least 800ms apart.
src/test/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetryTest.java Modified New case asserting throttle repeats queue for a pacing slot instead of jumping the limiter.

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 2
🔵 Low 0

Things to double-check

2 lower-confidence findings
  • MEDIUM: Verify all five content-creating methods dispatch through GitHubWriteRetry.call (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:107) (low confidence — verify before acting)
  • MEDIUM: Documented '0 disables pacing' value never exercised through the config converter (src/main/resources/application.properties:45) (low confidence — verify before acting)

⚠️ CI Checks Status

Some checks are still pending or have failed:

Check Type Status Detail
test check-run ⏳ Pending -
frontend check-run ⏳ Pending -
changes check-run ⏳ Pending -
trivy check-run ⏳ Pending -
format check-run ⏳ Pending -
actionlint check-run ⏳ Pending -
dependency-review check-run ⏳ Pending -

Automated review by ThrillhouseBot. Reply with /review to re-run.

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ThrillhouseBot noted 2 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):

  • MEDIUM: Verify all five content-creating methods dispatch through GitHubWriteRetry.call (src/main/java/dev/thiagogonzaga/thrillhousebot/github/GitHubWriteRetry.java:107)
    The PR's central claim is that GitHubWriteRetry#call is exactly the seam GitHub counts as content creation: createComment, updateComment, createReview, createPullRequestComment, replyToReviewComment. The changed line pacer.acquire(operation); only paces paths that reach this method, and the provided material demonstrates only one of the five paths reaching it: GitHubWritePacingTest drives GitHubCommentClient.createComment end-to-end, and GitHubWriteRetryTest.everyAttemptWaitsForItsPacingSlotIncludingTheRepeats invokes call(...) directly. The triggering scenarios in #579 — a review posting many inline findings, or several PRs reviewed concurrently — depend on createReview, replyToReviewComment, updateComment and createPullRequestComment routing through GitHubWriteRetry.call, but none of those client default methods is visible in the diff. Verify that all four dispatch through GitHubWriteRetry.call; if any bypasses it, that content-creating path remains unpaced and can still produce the burst this PR is meant to prevent.
  • MEDIUM: Documented '0 disables pacing' value never exercised through the config converter (src/main/resources/application.properties:45)
    Three places in this PR tell operators that GITHUB_WRITE_MIN_INTERVAL=0 disables pacing: application.properties line 45 (# GITHUB_WRITE_MIN_INTERVAL: spacing between two content-creating requests (0 disables pacing).), the ThrillhouseConfig.writeMinInterval() Javadoc, and the GitHubWritePacer.MIN_INTERVAL_KEY Javadoc. The only test of zero-interval behavior, GitHubWritePacerTest.aZeroIntervalTurnsPacingOffEntirely, constructs GitHubWritePacer with Duration.ZERO directly, bypassing configured() and the SmallRye DurationConverter; the knob test theEnvelopeIsAKnobAndFallsBackToTheGuidanceWhenItIsUnset only exercises the suffix form 250ms. Whether a bare "0" converts to Duration.ZERO — or is rejected, which would make GITHUB_WRITE_MIN_INTERVAL=0 fail Quarkus startup validation of the @ConfigMapping namespace and contradict the documented behavior — is not established by the provided material. Verify with the actual converter (e.g. boot a QuarkusTest with GITHUB_WRITE_MIN_INTERVAL=0, or call GitHubWritePacer.configured("thrillhousebot.github.write-min-interval", DEFAULT_MIN_INTERVAL) with the property set to 0); if conversion fails, the docs should say 0s so the documented disable path actually works.

…isables it

Two review findings asked for verification rather than reporting a defect,
and both check out: all five content-creating methods do reach
GitHubWriteRetry.call, and a bare "0" does convert to Duration.ZERO through
the SmallRye converter. Neither needed a code change; both were thin on
evidence, which is worth closing.

The end-to-end pacing proof drove only createComment, while the bursts #579
names — a review posting many inline findings, several PRs reviewed at once
— run through the review client. Fire all five at once instead, so a future
write path that skips the seam fails the test rather than silently
reintroducing the burst.

The documented "0 disables pacing" promise runs through the config
converter, while the only zero-interval test handed Duration.ZERO straight
to the constructor. Set the property, convert it, and pace a burst with the
result, since a converter that rejected the value would fail startup and
stay invisible until an operator reached for the knob.
@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot

Copy link
Copy Markdown

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 1
  • Previous findings resolved: 2
  • Previous findings still open: 0

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ThrillhouseBot noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):

  • LOW: New config keys not documented in README config table or .env.example (src/main/resources/application.properties:50)
    This PR adds two new environment variables/config keys: GITHUB_WRITE_MIN_INTERVAL -> thrillhousebot.github.write-min-interval and GITHUB_WRITE_MAX_WAIT -> thrillhousebot.github.write-max-wait. They are documented only in application.properties comments and in the ThrillhouseConfig Javadoc. The repository guidance requires every new config key to also appear in the README config table and in .env.example; neither file is changed in this diff. Operators using the standard config reference will not discover the new knobs. Add the two keys to README's config table and to .env.example.

@thrillhousebot thrillhousebot Bot added the java Pull requests that update java code label Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request java Pull requests that update java code performance Speed or resource-usage improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant