feat(stovepipe): admit latest head to build#335
Conversation
a384a94 to
86eaa35
Compare
c814927 to
acdd570
Compare
86eaa35 to
9008831
Compare
Compare accepted requests against queue.latest_request_id and mark older heads superseded. Latest heads remain accepted until admit lands in a follow-up PR.
Update process.md to describe coalescing via CompareRequestID and queue.latest_request_id instead of the dropped Sequence field and latest_request_seq pointer. Also align BuildStrategy naming with code.
Introduce queueconfig.Store and wire process to resolve per-queue max_concurrent at gate-check time. The default store returns global wiring constants; admit lands in a follow-up PR.
acdd570 to
98cef3f
Compare
9008831 to
44606ba
Compare
d3049fb to
99434b7
Compare
99434b7 to
2dc04df
Compare
behinddwalls
left a comment
There was a problem hiding this comment.
The happy path is correct and the version-arithmetic convention (assign after CAS success) is followed throughout — and nice job picking up the #324 feedback (empty-pointer log message, the TODOs). But the failure paths of the admit workflow have one real defect (the slot leak, first inline comment) and two shape issues that I think should be addressed before this merges.
The three main findings share one fix shape: claim slot → re-check staleness against the reloaded pointer → markProcessing returns whether it actually transitioned → on a lost race or error, compensating-decrement the slot.
| request.BuildStrategy = entity.BuildStrategyFull | ||
| request.BaseURI = "" | ||
|
|
||
| if err := c.markProcessing(ctx, &request); err != nil { |
There was a problem hiding this comment.
Slot leak / self-deadlock. If markProcessing fails with a retryable infra error (or the process crashes here), the slot stays claimed and the delivery redelivers with the request still accepted. On redelivery the gate sees in_flight_count = 1 >= max_concurrent — the request is blocked by its own claimed slot — and acks "awaiting build slot". Nothing ever decrements the count: no build was published, so record never sees it, and the ack means DLQ never sees it either. Permanent capacity loss for the queue and a permanently stuck head (a newer head later inherits the closed gate too).
Suggest a best-effort compensating decrement on the markProcessing error path before returning. That shrinks the hole to a hard crash between the two CASes — worth deciding how that residue gets reconciled (the RFC's record/DLQ (−1) table implies a janitor story; does it cover this?).
| if errors.Is(err, storage.ErrVersionMismatch) { | ||
| // claimBuildSlot reloaded queueRow; another admit may have taken the last slot. | ||
| if queueRow.InFlightCount >= maxConcurrent { | ||
| return fmt.Errorf("ProcessController gate closed for queue %s", queueRow.Name) |
There was a problem hiding this comment.
This returns "gate closed" as a plain (non-retryable) error → nack → DLQ, while the identical condition at the pre-check (L146) acks with a log. That's an expected outcome encoded as an error — against both the repo's "errors for failures, not control flow" rule and the RFC (gate closed → defer, never fail). It's near-unreachable today since concurrent queue-row writers only open the gate or touch other fields, but the path should ack (and later re-enqueue) like the pre-check does.
| if err == nil { | ||
| break | ||
| } | ||
| if errors.Is(err, storage.ErrVersionMismatch) { |
There was a problem hiding this comment.
A version mismatch on the queue row is most plausibly ingest stamping a new latest_request_id — i.e. a newer head just arrived. The RFC says every wake-up re-runs coalesce-then-gate, but this retry loop re-checks only the gate and then admits the now-stale head — spending the slot on exactly the intermediate the design promises to skip. After the reload, re-run CompareRequestID against the reloaded queueRow.LatestRequestID and supersede instead of admit when it's no longer latest.
| reqStore := c.store.GetRequestStore() | ||
|
|
||
| for { | ||
| if request.State != entity.RequestStateAccepted { |
There was a problem hiding this comment.
Returning nil when the reloaded state isn't accepted swallows a lost race: the caller then logs "admitted request to build" (and, once TODO(build-publish) lands, would publish a build) even if the request was actually superseded by another writer — while still holding the claimed slot. Suggest returning (transitioned bool, err error) so the caller can release the slot and skip the admit on false — this is also the natural hook for the compensating-decrement fix on the error path.
| } | ||
| } | ||
|
|
||
| func expectAdmit(m processMocks, id string) { |
There was a problem hiding this comment.
Coverage stops at the happy admit path — no cases for the claim-slot version-mismatch retry, gate-closed-after-reload, or the markProcessing lost-race, which is where all the review findings live. expectAdmit makes these cheap to add once the failure-path semantics are settled.
98cef3f to
0c435d1
Compare
|
This PR could not be automatically rebased after its base PR was merged. The rebase hit conflicts that need manual resolution. To fix manually: git fetch origin
git checkout mnoah1/stovepipe-process-admit
git rebase --onto origin/main 0c435d12ab8eeba953ea65e940c9deccd522d5cb mnoah1/stovepipe-process-admit
# resolve conflicts, then:
git push --force-with-leaseThen update this PR's base branch: gh pr edit 335 --base main |
What?
This PR adds in handling to admit a request to a build:
This leaves 3 main codepaths to fill out the rest of the initial implementation for the process step (marked with TODO):
Why?
Continuing to implement the
processstep outlined in process.md.Test Plan
make local-stovepipe-startgrpcurl -plaintext -d '{"queue":"monorepo/main"}' localhost:32773 uber.submitqueue.stovepipe.Stovepipe/Ingestadmitted request to build line below: