Skip to content

Commit 0f8cf76

Browse files
committed
feat(orchestrator): migrate the buildsignal poll loop to the hold primitive
## Summary ### Why? The orchestrator's buildsignal stage carried the same ack-and-republish poll loop the stovepipe stages just migrated off (previous commit): each non-terminal poll acked the delivery and PublishAfter'd the build id back to its own topic. Notably its republish reused the build id as the message id — the exact dedup collision that stalled stovepipe's loop (#465) was latent here — and a transient publish failure was wrapped retryable as the loop's only liveness, the awkward classification #469 removed on the stovepipe side. ### What? A non-terminal status now records a hold for the per-status poll delay and returns success; the framework postpones the delivery, which redelivers without counting toward the retry limit. publishBuild is deleted (the re-poll was its only caller); the speculate publish and halted-batch short-circuit are unchanged, and the DLQ reconciler is unaffected. The Process retryability comment is rewritten: the loop's continuation is framework-owned, so no publish needs a retryable wrap. build-runner.md's "Polling primitive" section is updated — hold supersedes the PublishAfter design it argued for, keeping the same retry_count semantics with no publisher, no minted ids, and no per-tick rows. ## Test Plan ✅ `bazel test //submitqueue/...` — NonTerminal cases assert Hold(per-status delay); Terminal/StatusError/UpdateStatusError/Halted cases fail on any Hold; the RepublishError test is deleted (a hold cannot fail). ✅ `make fmt`.
1 parent 155f446 commit 0f8cf76

5 files changed

Lines changed: 45 additions & 114 deletions

File tree

doc/rfc/submitqueue/build-runner.md

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The build stage needs a vendor-agnostic abstraction for talking to a Build Runne
1010

1111
## Flow
1212

13-
`build` triggers the runner and hands the `buildID` to the `buildsignal` poll loop. The loop calls `Status` on its own partition per build until the build is terminal: terminal results wake the batch state machine via `speculate`; non-terminal results re-enqueue the same `buildID` after a delay (`PublishAfter`). A webhook-capable backend can publish a status message into the same queue — the consumer cannot tell a push from a poll.
13+
`build` triggers the runner and hands the `buildID` to the `buildsignal` poll loop. The loop calls `Status` on its own partition per build until the build is terminal: terminal results wake the batch state machine via `speculate`; non-terminal results hold the delivery, so the same message redelivers after a delay. A webhook-capable backend can publish a status message into the same queue — the consumer cannot tell a push from a poll.
1414

1515
```
1616
┌────────────────────────────────────────────────────┐
@@ -29,8 +29,8 @@ The build stage needs a vendor-agnostic abstraction for talking to a Build Runne
2929
▼ ▼
3030
┌───────────────┐ ┌──────────────────────────────────┐
3131
│ terminal │ │ non-terminal │
32-
│ → speculate │ │ → PublishAfter(buildID, delay) │
33-
│ re-evaluate │ │ re-enqueues to buildsignal
32+
│ → speculate │ │ → hold(delay)
33+
│ re-evaluate │ │ same message redelivers
3434
└───────────────┘ └──────────────────────────────────┘
3535
```
3636

@@ -88,30 +88,21 @@ This makes polling behave like everything else in the orchestrator:
8888
- **Independent partitions** — slow polls on one build don't block others.
8989
- **Restart-safe** — pending polls live in the queue, not in memory.
9090
- **Retry-native** — a `Status` call that errors out is `Nack`'d and redelivered with the queue's normal backoff, separate from polling cadence.
91-
- **Tunable cadence**re-publish delay can vary by status (longer for `Accepted`, shorter for `Running`).
91+
- **Tunable cadence**the hold delay can vary by status (longer for `Accepted`, shorter for `Running`).
9292

93-
### Polling primitive: `PublishAfter`, not `Nack`
93+
### Polling primitive: hold, not `Nack`
9494

95-
Postponing the next poll needs a "publish-with-delay" verb. Two candidates exist in or near the queue extension:
95+
Postponing the next poll needs a "check back later" verb, and the consumer framework provides one: the controller records a hold on its delivery and returns success, and the framework postpones the message — it redelivers after the delay, and the redelivery is exempt from `retry_count` accounting (see [consumer-hold.md](../consumer-hold.md)). `Nack` remains the primitive for genuine `Status` failures, with its normal bounded-retry-then-DLQ behaviour. The two signals stay separate: `retry_count` means "consecutive failures," never "polls so far."
9696

97-
- **`Publisher.PublishAfter(topic, msg, delayMs)`** — a new primitive. A fresh message, made visible only after `delayMs`. The SQL-backed queue already has the column needed (`invisible_until`); `PublishAfter` is `Publish` with a non-zero delay.
98-
- **`Delivery.Nack(requeueAfterMs)`** — the existing primitive. Re-uses the same message, sets it invisible until `now + delay`, increments `retry_count`.
99-
100-
Both deliver the same surface behaviour: one message per build at a time, redelivered after the chosen delay. The difference is what `retry_count` means.
101-
102-
`Nack` is "this delivery failed, try again," and `retry_count` feeds `MaxAttempts` and DLQ. Using it for "build not yet done" overloads that counter — every poll bumps a number that is supposed to flag problems.
103-
104-
`PublishAfter` is "postpone this work." Each poll cycle is a fresh message with `retry_count = 0`. `Nack` stays available for true `Status` failures with its normal bounded-retry-then-DLQ behaviour. The two signals stay separate.
97+
An earlier revision of this design reached the same separation with `Publisher.PublishAfter` — ack the delivery, publish a fresh copy of the same message with delayed visibility. Hold supersedes it: no publisher in the poll loop, no fresh message ids to mint around the queue's publish dedup, no new log row per tick, and the loop's continuation no longer depends on an enqueue succeeding (a failed postpone write lapses into a normal visibility-timeout redelivery).
10598

10699
**Why not `Nack` with `MaxAttempts = ∞`** (one message per build, just keep cycling)? The mechanism works. Three things break:
107100

108101
- **No DLQ escape valve.** A malformed `buildID`, or a build the provider has lost, fails `Status` every call. With unbounded retries the message spins forever; the operator gets no signal that something is permanently wrong. DLQ exists for exactly this case; opting out for the buildsignal subscription means opting out of every poison-message signal it offers.
109102
- **Conflated metric.** `retry_count` is the obvious dashboard signal for "this consumer is having trouble." With infinite-retry polling, a `retry_count` of 500 might mean "build has been running 30 minutes" *or* "Status has errored 500 times" — operationally indistinguishable.
110103
- **Visibility-timeout coupling.** If the consumer crashes mid-poll before its `Nack`, the queue's visibility timeout redelivers the message and bumps `retry_count`. One number ends up counting legitimate polls, real errors, *and* consumer crashes — three signals fused.
111104

112-
`PublishAfter` costs one new queue primitive. It buys back the queue's diagnostic semantics.
113-
114-
Trade-off acknowledged: `PublishAfter` writes more — Ack deletes the old message, PublishAfter inserts a new one — vs `Nack` updating one row in place. At minute cadence the difference is noise; at second cadence it is real but small.
105+
Hold keeps all three signals separate for free: a deliberate postpone resets the failure streak, so `retry_count` counts only consecutive genuine failures.
115106

116107
### Push, when a backend supports it
117108

submitqueue/core/topickey/topickey.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ const (
3636
// TopicKeyBuildSignal is the polling stage for triggered builds. Each
3737
// message carries a Build; the consumer calls BuildRunner.Status,
3838
// persists the latest status, publishes the batch ID to TopicKeySpeculate
39-
// so the state machine re-evaluates, and re-publishes itself via
40-
// PublishAfter when the build has not yet reached a terminal state.
39+
// so the state machine re-evaluates, and holds the delivery for the next
40+
// poll when the build has not yet reached a terminal state.
4141
TopicKeyBuildSignal TopicKey = "buildsignal"
4242
// TopicKeyMerge is the pipeline stage where speculated batches are published for merging.
4343
TopicKeyMerge TopicKey = "submitqueue-merge"

submitqueue/orchestrator/controller/build/build.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
149149
}
150150

151151
// Hand off to the buildsignal poll loop; it calls Status, updates the
152-
// persisted Build, publishes to speculate, and re-publishes itself via
153-
// PublishAfter until terminal.
152+
// persisted Build, publishes to speculate, and holds its delivery
153+
// between polls until terminal.
154154
if err := c.publish(ctx, topickey.TopicKeyBuildSignal, build); err != nil {
155155
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
156156
return fmt.Errorf("failed to publish to buildsignal: %w", err)

submitqueue/orchestrator/controller/buildsignal/buildsignal.go

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
// Package buildsignal implements the build poll loop. Each message carries
1616
// a Build; the controller calls BuildRunner.Status, writes the latest
1717
// status to the BuildStore, publishes the batch ID to TopicKeySpeculate
18-
// so the state machine re-evaluates, and re-publishes itself via
19-
// PublishAfter when the build has not yet reached a terminal state. Each
20-
// buildID partitions independently, so slow polls on one build do not
21-
// block others. A webhook-capable backend can publish into this same
22-
// topic — the controller cannot tell a poll-driven message from a push.
18+
// so the state machine re-evaluates, and holds the delivery for the next
19+
// poll when the build has not yet reached a terminal state. Each message
20+
// partitions by batch ID, so slow polls on one batch's build do not block
21+
// others. A webhook-capable backend can publish into this same topic — the
22+
// controller cannot tell a poll-driven message from a push.
2323
package buildsignal
2424

2525
import (
@@ -89,18 +89,16 @@ func NewController(
8989
}
9090

9191
// Process polls the build's current status, persists it, publishes the
92-
// batch ID to speculate so the state machine re-evaluates, and re-publishes
93-
// a delayed message back to this topic when the build is still in flight.
92+
// batch ID to speculate so the state machine re-evaluates, and holds the
93+
// delivery for the next poll when the build is still in flight.
9494
// Returns nil to ack (success), or error to nack/reject.
9595
//
9696
// Error classification: deserialize, Status, Update, and the speculate
9797
// publish stay non-retryable — they reject straight to DLQ on the first
9898
// failure, where the operational republish path is the recovery mechanism.
99-
// Only the PublishAfter self-reschedule is retryable: it is the poll loop's
100-
// heartbeat and runs only after status/persist/speculate have all succeeded,
101-
// so a transient enqueue blip nacks and replays (up to MaxAttempts) rather
102-
// than silently stalling the build, then still falls through to DLQ if it
103-
// persists.
99+
// The poll loop's continuation is a hold, not a publish: the framework
100+
// postpones the delivery, and a failed postpone write lapses into a normal
101+
// visibility-timeout redelivery, so the loop cannot stall on an enqueue.
104102
func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) error {
105103
const opName = "process"
106104

@@ -187,14 +185,13 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
187185
return nil
188186
}
189187

188+
// Not terminal yet: hold the delivery so this same message redelivers
189+
// after the poll delay, without counting toward the retry limit.
190190
delayMs := pollDelay(status)
191191
metrics.NamedCounter(c.metricsScope, opName, "rescheduled", 1, metrics.NewTag("status", string(status)))
192-
if err := c.publishBuild(ctx, c.topicKey, updatedBuild, delayMs); err != nil {
193-
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
194-
return fmt.Errorf("failed to re-publish to buildsignal: %w", err)
195-
}
192+
delivery.Hold(delayMs)
196193

197-
c.logger.Debugw("rescheduled build status poll",
194+
c.logger.Debugw("holding for next build status poll",
198195
"build_id", updatedBuild.ID,
199196
"status", string(status),
200197
"delay_ms", delayMs,
@@ -213,34 +210,6 @@ func pollDelay(status entity.BuildStatus) int64 {
213210
}
214211
}
215212

216-
// publishBuild publishes a build's ID to the topic identified by key. delayMs > 0
217-
// uses PublishAfter; otherwise it uses Publish. Only the identifier travels on
218-
// the queue — the consumer reloads the full Build from storage.
219-
func (c *Controller) publishBuild(ctx context.Context, key consumer.TopicKey, build entity.Build, delayMs int64) error {
220-
payload, err := entity.BuildID{ID: build.ID}.ToBytes()
221-
if err != nil {
222-
return fmt.Errorf("failed to serialize build ID: %w", err)
223-
}
224-
225-
msg := entityqueue.NewMessage(build.ID, payload, build.BatchID, nil)
226-
227-
q, ok := c.registry.Queue(key)
228-
if !ok {
229-
return fmt.Errorf("no queue registered for topic key %s", key)
230-
}
231-
232-
topicName, ok := c.registry.TopicName(key)
233-
if !ok {
234-
return fmt.Errorf("no topic name registered for topic key %s", key)
235-
}
236-
237-
publisher := q.Publisher()
238-
if delayMs > 0 {
239-
return publisher.PublishAfter(ctx, topicName, msg, delayMs)
240-
}
241-
return publisher.Publish(ctx, topicName, msg)
242-
}
243-
244213
// publishBatchID publishes a batch ID to the topic identified by key.
245214
func (c *Controller) publishBatchID(ctx context.Context, key consumer.TopicKey, batchID string, partitionKey string) error {
246215
bid := entity.BatchID{ID: batchID}

submitqueue/orchestrator/controller/buildsignal/buildsignal_test.go

Lines changed: 19 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ import (
3636
)
3737

3838
// testHarness wires a Controller against mock queues for two topic keys
39-
// (buildsignal and speculate) so individual tests can assert which
40-
// Publish / PublishAfter happens.
39+
// (buildsignal and speculate) so individual tests can assert which publish
40+
// or hold happens.
4141
type testHarness struct {
4242
controller *Controller
4343
br *buildrunnermock.MockBuildRunner
@@ -93,8 +93,9 @@ func newTestHarness(t *testing.T, ctrl *gomock.Controller) *testHarness {
9393

9494
// buildDelivery builds a delivery whose payload is the build's ID, matching
9595
// the on-queue contract: only the identifier travels, the consumer loads the
96-
// full Build from storage.
97-
func buildDelivery(t *testing.T, ctrl *gomock.Controller, b entity.Build) consumer.Delivery {
96+
// full Build from storage. Tests that expect a hold add the expectation on
97+
// the returned mock.
98+
func buildDelivery(t *testing.T, ctrl *gomock.Controller, b entity.Build) *consumermock.MockDelivery {
9899
t.Helper()
99100
payload, err := entity.BuildID{ID: b.ID}.ToBytes()
100101
require.NoError(t, err)
@@ -117,8 +118,8 @@ func TestController_Identity(t *testing.T) {
117118
}
118119

119120
// TestController_Process_Terminal verifies a terminal poll persists the
120-
// status, publishes the batch ID to speculate, and does NOT re-publish to
121-
// buildsignal.
121+
// status, publishes the batch ID to speculate, and does NOT hold the
122+
// delivery for another poll.
122123
func TestController_Process_Terminal(t *testing.T) {
123124
tests := []struct {
124125
name string
@@ -150,7 +151,7 @@ func TestController_Process_Terminal(t *testing.T) {
150151
assert.Equal(t, build.BatchID, bid.ID)
151152
return nil
152153
}).Times(1)
153-
// No PublishAfter expected on terminal.
154+
// No Hold expected on terminal — any Hold call fails the test.
154155

155156
err := h.controller.Process(context.Background(), buildDelivery(t, ctrl, build))
156157
require.NoError(t, err)
@@ -159,8 +160,8 @@ func TestController_Process_Terminal(t *testing.T) {
159160
}
160161

161162
// TestController_Process_NonTerminal verifies a non-terminal poll persists
162-
// the status, publishes to speculate, AND re-publishes to buildsignal via
163-
// PublishAfter with the per-status delay.
163+
// the status, publishes to speculate, AND holds the delivery for the next
164+
// poll with the per-status delay.
164165
func TestController_Process_NonTerminal(t *testing.T) {
165166
tests := []struct {
166167
name string
@@ -185,17 +186,11 @@ func TestController_Process_NonTerminal(t *testing.T) {
185186
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
186187
h.buildStore.EXPECT().Update(gomock.Any(), updatedBuild).Return(nil)
187188
h.speculatePub.EXPECT().Publish(gomock.Any(), "speculate", gomock.Any()).Return(nil).Times(1)
188-
h.signalPub.EXPECT().
189-
PublishAfter(gomock.Any(), "buildsignal", gomock.AssignableToTypeOf(entityqueue.Message{}), tt.wantDelayMs).
190-
DoAndReturn(func(_ context.Context, _ string, msg entityqueue.Message, _ int64) error {
191-
bid, err := entity.BuildIDFromBytes(msg.Payload)
192-
require.NoError(t, err)
193-
// Re-published payload carries only the build ID.
194-
assert.Equal(t, build.ID, bid.ID)
195-
return nil
196-
}).Times(1)
197189

198-
err := h.controller.Process(context.Background(), buildDelivery(t, ctrl, build))
190+
d := buildDelivery(t, ctrl, build)
191+
d.EXPECT().Hold(tt.wantDelayMs)
192+
193+
err := h.controller.Process(context.Background(), d)
199194
require.NoError(t, err)
200195
})
201196
}
@@ -210,7 +205,7 @@ func TestController_Process_StatusError(t *testing.T) {
210205
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
211206
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
212207
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(entity.BuildStatusUnknown, nil, errors.New("provider down"))
213-
// No Update, no Publish, no PublishAfter expected.
208+
// No Update, no Publish, no Hold expected.
214209

215210
err := h.controller.Process(context.Background(), buildDelivery(t, ctrl, build))
216211
require.Error(t, err)
@@ -231,38 +226,14 @@ func TestController_Process_UpdateError(t *testing.T) {
231226
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
232227
h.buildStore.EXPECT().Update(gomock.Any(), updatedBuild).
233228
Return(errors.New("db unreachable"))
234-
// No Publish / PublishAfter expected after the store failure.
229+
// No Publish / Hold expected after the store failure.
235230

236231
err := h.controller.Process(context.Background(), buildDelivery(t, ctrl, build))
237232
require.Error(t, err)
238233
// Non-retryable: rejects to DLQ on first failure; republish is the recovery path.
239234
assert.False(t, errs.IsRetryable(err))
240235
}
241236

242-
// TestController_Process_RepublishError verifies that a failure to re-publish
243-
// the delayed poll message surfaces an error. The preceding
244-
// status/persist/speculate steps all succeed.
245-
func TestController_Process_RepublishError(t *testing.T) {
246-
ctrl := gomock.NewController(t)
247-
h := newTestHarness(t, ctrl)
248-
249-
build := entity.Build{ID: "b-5", BatchID: "batch-5", Status: entity.BuildStatusAccepted}
250-
updatedBuild := build
251-
updatedBuild.Status = entity.BuildStatusRunning
252-
253-
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
254-
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(entity.BuildStatusRunning, entity.BuildMetadata{}, nil)
255-
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
256-
h.buildStore.EXPECT().Update(gomock.Any(), updatedBuild).Return(nil)
257-
h.speculatePub.EXPECT().Publish(gomock.Any(), "speculate", gomock.Any()).Return(nil).Times(1)
258-
h.signalPub.EXPECT().
259-
PublishAfter(gomock.Any(), "buildsignal", gomock.Any(), PollDelayRunningMs).
260-
Return(errors.New("queue unavailable")).Times(1)
261-
262-
err := h.controller.Process(context.Background(), buildDelivery(t, ctrl, build))
263-
require.Error(t, err)
264-
}
265-
266237
// TestController_Process_GetError verifies that a failure to load the Build
267238
// from storage (only the ID is on the queue) surfaces an error. Non-retryable:
268239
// it rejects to DLQ on first failure, consistent with other storage reads.
@@ -315,9 +286,9 @@ func TestController_Process_HaltedShortCircuit(t *testing.T) {
315286
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
316287
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(entity.BuildStatusRunning, entity.BuildMetadata{}, nil)
317288
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: state}, nil)
318-
// Halted: no Update, no speculate Publish, no buildsignal
319-
// PublishAfter. The harness publishers have no expectations, so any
320-
// publish fails the test.
289+
// Halted: no Update, no speculate Publish, no Hold. The
290+
// harness publishers have no expectations, so any publish fails
291+
// the test.
321292

322293
require.NoError(t, h.controller.Process(context.Background(), buildDelivery(t, ctrl, build)))
323294
})

0 commit comments

Comments
 (0)