A queue with no backoff floods on every tick
My approval queue filled with the same pull request on every tick, and the thing it kept trying to merge could never merge.
By Andrew Pyle
A loop that forgets its failures will retry them for as long as it keeps running.
01Every ten minutes
One pull request, up to 144 tries a day
My merge-on-green poller runs every ten minutes. At that cadence, a pull request that gets re-enqueued on every tick could produce up to 144 attempts a day. I never counted how many it actually produced. I know the shape of it. Each tick made a new merge request in my approval queue, that request failed, and the queue gained one more failed row.
The poller exists to close a gap. My auto-approval classifier only fires at the instant an agent enqueues a merge, so a pull request that turns green later never gets looked at again. Since August the poller has rechecked open pull requests on each tick, handed every eligibility decision to the classifier, which approves only docs-only changes with green checks and fails closed, and enqueued an already-approved merge for the existing executor to carry out.
That design kept the poller small. It had no opinions of its own. It also meant it would propose anything the classifier liked, including something the executor could never complete.
02A held draft
The classifier liked it and the executor could not merge it
The item it could not let go of was a docs-only runbook I had opened as a draft in late September and marked held. I mark a pull request do-not-merge by putting (HELD) in its title. A runbook is docs-only, so the classifier saw it as eligible. It was a draft, so every merge attempt failed.
Two separate holes let that repeat. The search the poller ran asked for open pull requests and had no filter for drafts, so drafts came back as candidates. And the idempotency check, the one meant to keep the poller from enqueuing the same pull request twice, looked for a merge that was pending, approved, executing, or done. Failed was not on the list. Once the first merge failed, the check saw nothing in flight, and the next tick enqueued it again.
_MERGE_INFLIGHT_STATUSES = ('pending', 'approved', 'executing', 'done')The flood stopped on its own on October 6, when I closed the draft. Nothing in the code had changed. The cause was still on main, waiting for the next draft that looked like docs, and I came back to fix it six days later.
03Three guards, in order
Skip what cannot merge before asking anything
The search now asks for non-draft pull requests, and the poller also drops any returned item still flagged as a draft. I did not run the new search against my organization before merging. The qualifier is documented, but I had not watched it work, so the second check is there in case it behaves differently from the docs.
before: is:pr state:open org:<owner>
after: the same search with draft:false added,
plus a code check that drops any item still marked draftA title containing (HELD) is now skipped before the classifier runs. The code honors a naming convention I use by hand, which is less tidy than a structured flag. It is also the signal I actually set.
A pull request whose merge failed in the last six hours is skipped as well. The backoff looks for a failed merge approval for the same repository and pull request number, created inside that window.
The checks run in a fixed order. The held check runs first, before any database or GitHub call. The cheap in-flight check runs second, so a duplicate skips the GitHub round-trips. The backoff runs third, and only then does the classifier see the pull request. Tests assert that the classifier is never called for a held or backed-off one. The result also gained two counters, one for held skips and one for backoff skips, so a quiet poller can be told apart from a broken one.
04What the wait costs
Six hours is a blunt number
Six new tests cover the guards. One has a failed merge ten minutes old and checks that it backs off. Another has a failure five minutes past the window and checks that it does not block a retry. With the older poller tests, 11 passed. I also ran the removal check: with the held and backoff checks disabled, the held-title test and the recent-failure test both fail.
The backoff is keyed to the pull request, not to the commit it was tested on. A real docs-only pull request that fails once for a fixable reason, such as a conflict I then resolve, still waits up to six hours before the poller tries again. I wrote that worst case into the change, in plain words, and kept it.
A real docs-only PR whose merge failed once waits 6 h before a retry.
The change was open for 21 minutes. After deploy, the poller's result should show the two new counters, and no pull request should collect more than one failed merge row in six hours. That check is filed. Until it runs, I know the guards pass their tests, and I do not yet know they hold in production.
Related
writing
A close that would not stick
I was about to clean up a third of my task board, and the code that filed those rows would have put them all back.
writing
A dry run that spent money
The flag meant to make a rehearsal safe was making paid model calls and writing real rows.
writing
Dead is not asleep
A liveness alarm kept telling me to revive a laptop that was healthy and asleep, until I changed what the alarm counts as dead.
writing
Active was measuring the wrong thing
My overnight builder thought I was awake from 1 to 7 AM, because the signal it trusted for awake was being touched by something that was not me.
writing
The publishing pipeline that lied to me for four days
A green log, a stale site, and the difference between a job that ran and a job that did the thing.