fix(stovepipe): mint a distinct message id for each buildsignal re-poll - #465
Merged
Conversation
This was referenced Jul 29, 2026
behinddwalls
marked this pull request as ready for review
July 29, 2026 23:06
roychying
reviewed
Jul 30, 2026
behinddwalls
force-pushed
the
preetam/stovepipe-poll-id
branch
from
July 30, 2026 07:51
e80ad12 to
28977bd
Compare
mnoah1
approved these changes
Jul 30, 2026
behinddwalls
force-pushed
the
preetam/stovepipe-poll-id
branch
from
July 30, 2026 18:20
28977bd to
69eec72
Compare
github-merge-queue
Bot
removed this pull request from the merge queue due to invalid changes in the merge commit
Jul 30, 2026
## Summary
### Why?
`buildsignal` schedules its next poll by re-publishing to its own topic, and it reused the build id as the message id — byte-identical to the message `build` published to start the loop. The MySQL queue dedups on the `(topic, partition_key, id)` unique key and `InsertDelayed` swallows the collision with `ON DUPLICATE KEY UPDATE topic = topic`, returning success. So the reschedule was accepted and silently discarded.
This is deterministic, not a race. The re-publish happens before the delivery is acked, and GC only collects up to the minimum *acked* offset on idle ticks, so the colliding row is always still present.
The effect: any build that is not terminal on its first poll is never polled again. `Build.Status` freezes at `accepted`/`running`, the request never leaves `processing`, nothing is published to `record`, and the queue's `in_flight_count` slot is never released — so after `MaxConcurrent` such builds the queue stops admitting work entirely.
Nothing caught it because the fake build runner could not report a non-terminal status until the previous commit, and the unit tests matched the published message with `gomock.Any()`.
### What?
`publishBuildSignal` now mints `{buildID}/poll/{generation}`, where the generation is read off the id of the delivery being processed and incremented — so a chain runs `B` -> `B/poll/1` -> `B/poll/2` -> … The partition key stays the build id, so each build's poll loop keeps its own partition.
The generation advances deterministically rather than randomly, which matters for the case a random suffix handles badly. The next id is a pure function of the delivery, so a redelivery racing the original computes the *same* id and dedup collapses the two into one message: the build keeps a single poll chain. A random suffix would instead fork a second chain, doubling the poll rate and racing the first chain's status CAS for no benefit.
A stable id is not an option in the other direction either — that is the bug itself. Even a fixed-but-different id like `B/poll` only survives one extra tick, because the second tick's re-poll then collides with the message being processed.
## Test Plan
✅ `bazel test //stovepipe/...` — new unit test asserts successive re-polls mint distinct ids and never reuse the build id. Verified it genuinely regresses: reverting just the id line fails it with `"map[bk-1:{}]" should have 3 item(s), but has 1`.
✅ `bazel test //test/e2e/stovepipe/...` — new e2e ingests a queue carrying the `build-slow` marker and waits for the build row to reach `succeeded`, which requires more than one poll tick. The service log shows three distinct ids on the `buildsignal` topic for one build.
behinddwalls
force-pushed
the
preetam/stovepipe-poll-id
branch
from
July 30, 2026 18:31
69eec72 to
0b4f789
Compare
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.
Summary
Why?
buildsignalschedules its next poll by re-publishing to its own topic, and it reused the build id as the message id — byte-identical to the messagebuildpublished to start the loop. The MySQL queue dedups on the(topic, partition_key, id)unique key andInsertDelayedswallows the collision withON DUPLICATE KEY UPDATE topic = topic, returning success. So the reschedule was accepted and silently discarded.This is deterministic, not a race. The re-publish happens before the delivery is acked, and GC only collects up to the minimum acked offset on idle ticks, so the colliding row is always still present.
The effect: any build that is not terminal on its first poll is never polled again.
Build.Statusfreezes ataccepted/running, the request never leavesprocessing, nothing is published torecord, and the queue'sin_flight_countslot is never released — so afterMaxConcurrentsuch builds the queue stops admitting work entirely.Nothing caught it because the fake build runner could not report a non-terminal status until the previous commit, and the unit tests matched the published message with
gomock.Any().What?
publishBuildSignalnow mints{buildID}/poll/{generation}, where the generation is read off the id of the delivery being processed and incremented — so a chain runsB->B/poll/1->B/poll/2-> … The partition key stays the build id, so each build's poll loop keeps its own partition.The generation advances deterministically rather than randomly, which matters for the case a random suffix handles badly. The next id is a pure function of the delivery, so a redelivery racing the original computes the same id and dedup collapses the two into one message: the build keeps a single poll chain. A random suffix would instead fork a second chain, doubling the poll rate and racing the first chain's status CAS for no benefit.
A stable id is not an option in the other direction either — that is the bug itself. Even a fixed-but-different id like
B/pollonly survives one extra tick, because the second tick's re-poll then collides with the message being processed.Test Plan
✅
bazel test //stovepipe/...— new unit test asserts successive re-polls mint distinct ids and never reuse the build id. Verified it genuinely regresses: reverting just the id line fails it with"map[bk-1:{}]" should have 3 item(s), but has 1.✅
bazel test //test/e2e/stovepipe/...— new e2e ingests a queue carrying thebuild-slowmarker and waits for the build row to reachsucceeded, which requires more than one poll tick. The service log shows three distinct ids on thebuildsignaltopic for one build.Stack