Skip to content

Commit 2e371cf

Browse files
authored
feat(orchestrator): dispatch builds per speculation path (#503)
## Summary ### Why? The build stage triggered one build per batch, on the batch's full dependency list. Speculation needs one build per *path*, on the subset of dependencies the path assumes will succeed — that subset is what lets a batch be verified before the batches ahead of it resolve. ### What? The dispatch controller now reads the head's path set and starts a build for every pending path, on that path's own base. It **only starts builds** — stopping them belongs to the poll loop (next commit) — and it **never writes the path set**, which stays the speculate run's single-writer state; what this stage knows is the build it started, recorded in per-build rows of its own. The write order is Trigger → Build record → write-once link (`entity.PathBuild`) → signal; each write makes the previous one reachable, and the link is the idempotency point for redeliveries. A lost link race publishes both builds' signals and acks — the poll loop keeps the one the link names. On redeliveries only, every live linked path gets its signal re-published, closing the crash window between link and signal without forking duplicate poll chains past the queue's dedup horizon. The build signal carries only the runner's build ID and partitions on it, so one slow build cannot block a head's other paths. Known gap: a crash between `Trigger` and the link orphans that build; the fix is an idempotency key on `BuildRunner.Trigger`, marked TODO. Also adds `submitqueue/core/publish` — the registry-lookup-and-send plumbing and the message-ID deduplication rule in one place. ## Test Plan ✅ `bazel test //submitqueue/orchestrator/controller/build/... //submitqueue/core/publish/...` — write order, the base holding only assumed-success dependencies, redelivery republish without rebuild, lost-race double signal, halted batch starting nothing, redelivery healing for live linked paths, and a pin that no path-set write of any kind happens. ✅ `make fmt`, `make gazelle`, `make mocks` ## Issues
1 parent f177f67 commit 2e371cf

6 files changed

Lines changed: 800 additions & 352 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["publish.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/core/publish",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//platform/base/messagequeue:go_default_library",
10+
"//platform/consumer:go_default_library",
11+
],
12+
)
13+
14+
go_test(
15+
name = "go_default_test",
16+
srcs = ["publish_test.go"],
17+
embed = [":go_default_library"],
18+
deps = [
19+
"//platform/base/messagequeue:go_default_library",
20+
"//platform/consumer:go_default_library",
21+
"//platform/extension/messagequeue/mock:go_default_library",
22+
"@com_github_stretchr_testify//assert:go_default_library",
23+
"@com_github_stretchr_testify//require:go_default_library",
24+
"@org_uber_go_mock//gomock:go_default_library",
25+
],
26+
)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package publish sends a message to the queue behind a topic key. It owns the
16+
// lookup-and-send plumbing every orchestrator stage otherwise repeats — resolve
17+
// the key to a queue and a topic name, wrap the payload in a message, publish —
18+
// and the message-ID convention that controls deduplication (see UniqueID).
19+
package publish
20+
21+
import (
22+
"context"
23+
"fmt"
24+
"sync/atomic"
25+
"time"
26+
27+
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
28+
"github.com/uber/submitqueue/platform/consumer"
29+
)
30+
31+
// Message publishes payload to the topic registered for key.
32+
//
33+
// msgID selects the dedup behavior, so the caller must choose it deliberately.
34+
// The queue deduplicates on (topic, partition key, message ID) against every
35+
// row it has not garbage-collected yet, consumed ones included:
36+
//
37+
// - A stable msgID (an entity's own ID) makes a repeat publish a silent
38+
// no-op. Right for a hand-off that must happen at most once per entity.
39+
// - UniqueID(id) makes every publish distinct. Right for signals that are
40+
// re-sent by design — wake-ups, polls, re-dispatches — where a swallowed
41+
// repeat would stall the pipeline.
42+
func Message(ctx context.Context, registry consumer.TopicRegistry, key consumer.TopicKey, msgID string, payload []byte, partitionKey string) error {
43+
q, ok := registry.Queue(key)
44+
if !ok {
45+
return fmt.Errorf("no queue registered for topic key %s", key)
46+
}
47+
topicName, ok := registry.TopicName(key)
48+
if !ok {
49+
return fmt.Errorf("no topic name registered for topic key %s", key)
50+
}
51+
52+
msg := entityqueue.NewMessage(msgID, payload, partitionKey, nil)
53+
return q.Publisher().Publish(ctx, topicName, msg)
54+
}
55+
56+
// sequence breaks ties between UniqueID calls that land on the same clock
57+
// tick: some platforms quantize time.Now coarsely enough for consecutive calls
58+
// to read the same nanosecond.
59+
var sequence atomic.Uint64
60+
61+
// UniqueID returns a message ID no earlier publish for the same entity has
62+
// used, so the queue's (topic, partition key, message ID) dedup never swallows
63+
// the repeat. Use it for every publish that is re-sent by design; reusing the
64+
// bare entity ID instead would make the second publish a silent no-op.
65+
func UniqueID(id string) string {
66+
return fmt.Sprintf("%s@%d-%d", id, time.Now().UnixNano(), sequence.Add(1))
67+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package publish
16+
17+
import (
18+
"context"
19+
"strings"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
25+
"github.com/uber/submitqueue/platform/consumer"
26+
queuemock "github.com/uber/submitqueue/platform/extension/messagequeue/mock"
27+
"go.uber.org/mock/gomock"
28+
)
29+
30+
const testKey consumer.TopicKey = "test-topic-key"
31+
32+
func newTestRegistry(t *testing.T, ctrl *gomock.Controller) (consumer.TopicRegistry, *queuemock.MockPublisher) {
33+
t.Helper()
34+
35+
publisher := queuemock.NewMockPublisher(ctrl)
36+
q := queuemock.NewMockQueue(ctrl)
37+
q.EXPECT().Publisher().Return(publisher).AnyTimes()
38+
39+
registry, err := consumer.NewTopicRegistry(
40+
[]consumer.TopicConfig{{Key: testKey, Name: "test-topic", Queue: q}},
41+
)
42+
require.NoError(t, err)
43+
return registry, publisher
44+
}
45+
46+
func TestMessage(t *testing.T) {
47+
ctrl := gomock.NewController(t)
48+
registry, publisher := newTestRegistry(t, ctrl)
49+
50+
var published entityqueue.Message
51+
publisher.EXPECT().
52+
Publish(gomock.Any(), "test-topic", gomock.Any()).
53+
DoAndReturn(func(_ context.Context, _ string, msg entityqueue.Message) error {
54+
published = msg
55+
return nil
56+
})
57+
58+
err := Message(context.Background(), registry, testKey, "msg-1", []byte("payload"), "partition-1")
59+
require.NoError(t, err)
60+
assert.Equal(t, "msg-1", published.ID)
61+
assert.Equal(t, []byte("payload"), published.Payload)
62+
assert.Equal(t, "partition-1", published.PartitionKey)
63+
}
64+
65+
func TestMessage_UnregisteredKey(t *testing.T) {
66+
ctrl := gomock.NewController(t)
67+
registry, _ := newTestRegistry(t, ctrl)
68+
69+
err := Message(context.Background(), registry, "unregistered-key", "msg-1", []byte("payload"), "partition-1")
70+
require.Error(t, err)
71+
}
72+
73+
func TestUniqueID(t *testing.T) {
74+
a := UniqueID("batch-1")
75+
b := UniqueID("batch-1")
76+
77+
assert.True(t, strings.HasPrefix(a, "batch-1@"))
78+
assert.True(t, strings.HasPrefix(b, "batch-1@"))
79+
assert.NotEqual(t, a, b)
80+
}

submitqueue/orchestrator/controller/build/BUILD.bazel

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ go_library(
66
importpath = "github.com/uber/submitqueue/submitqueue/orchestrator/controller/build",
77
visibility = ["//visibility:public"],
88
deps = [
9-
"//platform/base/messagequeue:go_default_library",
109
"//platform/consumer:go_default_library",
1110
"//platform/metrics:go_default_library",
11+
"//submitqueue/core/publish:go_default_library",
1212
"//submitqueue/core/topickey:go_default_library",
1313
"//submitqueue/entity:go_default_library",
1414
"//submitqueue/extension/buildrunner:go_default_library",
@@ -26,13 +26,10 @@ go_test(
2626
"//platform/base/messagequeue:go_default_library",
2727
"//platform/consumer:go_default_library",
2828
"//platform/consumer/mock:go_default_library",
29-
"//platform/errs:go_default_library",
3029
"//platform/extension/messagequeue/mock:go_default_library",
31-
"//submitqueue/core/changeset/fake:go_default_library",
3230
"//submitqueue/core/topickey:go_default_library",
3331
"//submitqueue/entity:go_default_library",
3432
"//submitqueue/extension/buildrunner:go_default_library",
35-
"//submitqueue/extension/buildrunner/fake:go_default_library",
3633
"//submitqueue/extension/buildrunner/mock:go_default_library",
3734
"//submitqueue/extension/storage:go_default_library",
3835
"//submitqueue/extension/storage/mock:go_default_library",

0 commit comments

Comments
 (0)