Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions service/submitqueue/orchestrator/server/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ go_library(
"//submitqueue/extension/scorer/composite:go_default_library",
"//submitqueue/extension/scorer/fake:go_default_library",
"//submitqueue/extension/scorer/heuristic:go_default_library",
"//submitqueue/extension/speculation/prioritizationlimit/static:go_default_library",
"//submitqueue/extension/speculation/prioritizer:go_default_library",
"//submitqueue/extension/speculation/prioritizer/sticky:go_default_library",
"//submitqueue/extension/storage:go_default_library",
"//submitqueue/extension/storage/mysql:go_default_library",
"//submitqueue/extension/validator/fake:go_default_library",
Expand All @@ -54,6 +57,7 @@ go_library(
"//submitqueue/orchestrator/controller/merge:go_default_library",
"//submitqueue/orchestrator/controller/mergeconflictsignal:go_default_library",
"//submitqueue/orchestrator/controller/mergesignal:go_default_library",
"//submitqueue/orchestrator/controller/prioritize:go_default_library",
"//submitqueue/orchestrator/controller/score:go_default_library",
"//submitqueue/orchestrator/controller/speculate:go_default_library",
"//submitqueue/orchestrator/controller/start:go_default_library",
Expand Down
62 changes: 53 additions & 9 deletions service/submitqueue/orchestrator/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ import (
"github.com/uber/submitqueue/submitqueue/extension/scorer/composite"
scorerfake "github.com/uber/submitqueue/submitqueue/extension/scorer/fake"
"github.com/uber/submitqueue/submitqueue/extension/scorer/heuristic"
prioritizationlimitstatic "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit/static"
"github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer"
"github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer/sticky"
"github.com/uber/submitqueue/submitqueue/extension/storage"
mysqlstorage "github.com/uber/submitqueue/submitqueue/extension/storage/mysql"
validatorfake "github.com/uber/submitqueue/submitqueue/extension/validator/fake"
Expand All @@ -74,6 +77,7 @@ import (
"github.com/uber/submitqueue/submitqueue/orchestrator/controller/merge"
"github.com/uber/submitqueue/submitqueue/orchestrator/controller/mergeconflictsignal"
"github.com/uber/submitqueue/submitqueue/orchestrator/controller/mergesignal"
"github.com/uber/submitqueue/submitqueue/orchestrator/controller/prioritize"
"github.com/uber/submitqueue/submitqueue/orchestrator/controller/score"
"github.com/uber/submitqueue/submitqueue/orchestrator/controller/speculate"
"github.com/uber/submitqueue/submitqueue/orchestrator/controller/start"
Expand Down Expand Up @@ -244,13 +248,14 @@ func run() error {
brf := buildRunnerFactory{queues}
scf := scorerFactory{queues}
cof := analyzerFactory{queues}
prf := prioritizerFactory{queues}

// Register controllers
primaryCount, err := registerPrimaryControllers(primaryConsumer, logger.Sugar(), scope, registry, cpf, brf, scf, cof, cnt, store)
primaryCount, err := registerPrimaryControllers(primaryConsumer, logger.Sugar(), scope, registry, cpf, brf, scf, cof, prf, cnt, store)
if err != nil {
return err
}
dlqCount, err := registerDLQControllers(dlqConsumer, logger.Sugar(), scope, store)
dlqCount, err := registerDLQControllers(dlqConsumer, logger.Sugar(), scope, registry, store)
if err != nil {
return err
}
Expand Down Expand Up @@ -380,6 +385,7 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
{topickey.TopicKeyBatch, "batch", "orchestrator-batch"},
{topickey.TopicKeyScore, "score", "orchestrator-score"},
{topickey.TopicKeySpeculate, "speculate", "orchestrator-speculate"},
{topickey.TopicKeyPrioritize, "prioritize", "orchestrator-prioritize"},
{topickey.TopicKeyBuild, "build", "orchestrator-build"},
{topickey.TopicKeyBuildSignal, "buildsignal", "orchestrator-buildsignal"},
{topickey.TopicKeyMerge, "merge", "orchestrator-merge"},
Expand Down Expand Up @@ -471,6 +477,13 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
// merge-conflict-check queue (⇢); runway performs the merge attempt and
// publishes the result to merge-conflict-check-signal, which mergeconflictsignal
// consumes before fanning the request out to batch.
//
// prioritize sits alongside this per-batch flow rather than in its line: it
// is queue-wide, not batch-scoped. Its message carries only a queue name; on
// each invocation it loads every Speculating batch's speculation tree for
// that queue, ranks the queue-wide candidate paths against the queue's build
// budget, applies the resulting decisions, and republishes to build for any
// path newly (or still) cleared to run.

// TODO(wiring abstraction): queueExtensions + queueRegistry currently live here
// as example-local wiring. Evaluate promoting them into a defined abstraction in
Expand All @@ -493,6 +506,7 @@ type queueExtensions struct {
buildRunner buildrunner.BuildRunner
scorer scorer.Scorer
analyzer conflict.Analyzer
prioritizer prioritizer.Prioritizer
}

// queueRegistry maps a queue name to its extensions, falling back to a default
Expand Down Expand Up @@ -538,7 +552,13 @@ func (f analyzerFactory) For(cfg conflict.Config) (conflict.Analyzer, error) {
return f.reg.get(cfg.QueueName).analyzer, nil
}

func registerPrimaryControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, cpf changeprovider.Factory, brf buildrunner.Factory, scf scorer.Factory, cof conflict.Factory, cnt counter.Counter, store storage.Storage) (int, error) {
type prioritizerFactory struct{ reg queueRegistry }

func (f prioritizerFactory) For(cfg prioritizer.Config) (prioritizer.Prioritizer, error) {
return f.reg.get(cfg.QueueName).prioritizer, nil
}

func registerPrimaryControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, cpf changeprovider.Factory, brf buildrunner.Factory, scf scorer.Factory, cof conflict.Factory, prf prioritizer.Factory, cnt counter.Counter, store storage.Storage) (int, error) {
var count int
requestController := start.NewController(
logger,
Expand Down Expand Up @@ -637,6 +657,20 @@ func registerPrimaryControllers(c consumer.Consumer, logger *zap.SugaredLogger,
}
count++

prioritizeController := prioritize.NewController(
logger,
scope,
store,
prf,
registry,
topickey.TopicKeyPrioritize,
"orchestrator-prioritize",
)
if err := c.Register(prioritizeController); err != nil {
return count, fmt.Errorf("failed to register prioritize controller: %w", err)
}
count++

buildController := build.NewController(
logger,
scope,
Expand Down Expand Up @@ -712,7 +746,7 @@ func registerPrimaryControllers(c consumer.Consumer, logger *zap.SugaredLogger,
// registers them with the DLQ consumer. Each reconciler drives the affected
// request or batch into a terminal Error/Failed state so the gateway stops
// reporting it as stuck-in-progress.
func registerDLQControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, store storage.Storage) (int, error) {
func registerDLQControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, store storage.Storage) (int, error) {
dlqScope := scope.SubScope("dlq")
dlqRegs := []struct {
name string
Expand All @@ -725,6 +759,7 @@ func registerDLQControllers(c consumer.Consumer, logger *zap.SugaredLogger, scop
{"batch_dlq", dlq.NewDLQRequestController(logger, dlqScope, store, dlq.DecodeRequestID, dlq.TopicKey(topickey.TopicKeyBatch), "orchestrator-batch-dlq")},
{"score_dlq", dlq.NewDLQBatchController(logger, dlqScope, store, dlq.TopicKey(topickey.TopicKeyScore), "orchestrator-score-dlq")},
{"speculate_dlq", dlq.NewDLQBatchController(logger, dlqScope, store, dlq.TopicKey(topickey.TopicKeySpeculate), "orchestrator-speculate-dlq")},
{"prioritize_dlq", dlq.NewDLQQueueController(logger, dlqScope, registry, dlq.TopicKey(topickey.TopicKeyPrioritize), "orchestrator-prioritize-dlq")},
{"build_dlq", dlq.NewDLQBatchController(logger, dlqScope, store, dlq.TopicKey(topickey.TopicKeyBuild), "orchestrator-build-dlq")},
{"buildsignal_dlq", dlq.NewDLQBuildSignalController(logger, dlqScope, store, dlq.TopicKey(topickey.TopicKeyBuildSignal), "orchestrator-buildsignal-dlq")},
{"merge_dlq", dlq.NewDLQBatchController(logger, dlqScope, store, dlq.TopicKey(topickey.TopicKeyMerge), "orchestrator-merge-dlq")},
Expand Down Expand Up @@ -859,6 +894,11 @@ func newPhabChangeProvider(logger *zap.Logger, scope tally.Scope) (changeprovide
}), nil
}

// defaultPrioritizationLimit is the baseline queue-wide concurrent-build
// budget handed to the sticky prioritizer. It is a parity default —
// effectively admit-all — until per-queue budgets are configured.
const defaultPrioritizationLimit = 1000

// newQueueRegistry builds the per-queue extension profiles for the example.
// Edge integrations (change provider) and the build
// runner form a shared baseline; each per-queue profile starts from that
Expand All @@ -880,16 +920,19 @@ func newQueueRegistry(logger *zap.Logger, scope tally.Scope, resolver changeset.

// Baseline profile: shared edge integrations + a fake build runner (every
// build succeeds unless a head URI carries a failure marker), plus permissive
// defaults for scorer and conflict. The build runner instance is shared by
// the build and buildsignal controllers (same profile, same instance) so a
// build's recorded outcome survives across their separate factory lookups.
// defaults for scorer, conflict, and prioritization. The build runner
// instance is shared by the build and buildsignal controllers (same
// profile, same instance) so a build's recorded outcome survives across
// their separate factory lookups.
//
// The scorer is wrapped by scorerfake so a change URI carrying
// "sq-fake=score-error" forces a scoring error end-to-end; it is a pure
// passthrough otherwise. The analyzer is wrapped by conflictfake with a nil
// predicate (passthrough) — swap the predicate (e.g. conflictfake.FailAlways)
// on a queue to exercise the analyzer error path, as e2e-conflict-error-queue
// below does.
// below does. The prioritizer is sticky over a static budget: it never
// preempts a running build and admits Selected candidates by score until
// defaultPrioritizationLimit concurrent builds are in flight.
base := queueExtensions{
changeProvider: cp,
buildRunner: buildfake.New(resolver),
Expand All @@ -900,7 +943,8 @@ func newQueueRegistry(logger *zap.Logger, scope tally.Scope, resolver changeset.
)),
// TODO: replace the delegate with a real analyzer (e.g. Tango target
// analysis). "all" serializes the queue conservatively.
analyzer: conflictfake.New(all.New(), nil),
analyzer: conflictfake.New(all.New(), nil),
prioritizer: sticky.New(prioritizationlimitstatic.New(defaultPrioritizationLimit)),
}

// test-queue: bucketed heuristic scorer; conservative (serialized) conflicts
Expand Down
8 changes: 8 additions & 0 deletions submitqueue/core/topickey/topickey.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ const (
TopicKeyScore TopicKey = "score"
// TopicKeySpeculate is the pipeline stage where scored batches are published for speculation.
TopicKeySpeculate TopicKey = "speculate"
// TopicKeyPrioritize is the queue-wide reconcile stage that rations the
// build budget across every in-flight batch of a queue. Each message
// carries a QueueID; the consumer loads every Speculating batch's tree,
// runs the queue's Prioritizer over the candidate paths, applies the
// resulting decisions — promoting paths into the build budget, or
// cancelling in-flight paths a preemptive policy evicts — and republishes
// to TopicKeyBuild for any path cleared to run.
TopicKeyPrioritize TopicKey = "prioritize"
// TopicKeyBuild is the pipeline stage where speculated batches are published for builds.
TopicKeyBuild TopicKey = "build"
// TopicKeyBuildSignal is the polling stage for triggered builds. Each
Expand Down
2 changes: 2 additions & 0 deletions submitqueue/entity/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ go_library(
"land_request.go",
"merge_result.go",
"push_result.go",
"queue.go",
"queue_config.go",
"request.go",
"request_log.go",
Expand All @@ -36,6 +37,7 @@ go_test(
"build_test.go",
"cancel_request_test.go",
"land_request_test.go",
"queue_test.go",
"request_log_test.go",
"request_test.go",
"speculation_tree_test.go",
Expand Down
36 changes: 36 additions & 0 deletions submitqueue/entity/queue.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2025 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package entity

import "encoding/json"

// QueueID is the queue-message payload for queue-scoped pipeline stages. It
// carries only the queue name.
type QueueID struct {
// Name is the merge-queue name the message targets.
Name string `json:"name"`
}

// ToBytes serializes the QueueID to JSON bytes for queue message payload.
func (q QueueID) ToBytes() ([]byte, error) {
return json.Marshal(q)
}

// QueueIDFromBytes deserializes a QueueID from JSON bytes.
func QueueIDFromBytes(data []byte) (QueueID, error) {
var qid QueueID
err := json.Unmarshal(data, &qid)
return qid, err
}
72 changes: 72 additions & 0 deletions submitqueue/entity/queue_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) 2025 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package entity

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestQueueID_SerializationRoundTrip(t *testing.T) {
tests := []struct {
name string
queueID QueueID
}{
{
name: "simple queue name",
queueID: QueueID{Name: "queueA"},
},
{
name: "another queue name",
queueID: QueueID{Name: "queueB"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := tt.queueID.ToBytes()
require.NoError(t, err)

deserialized, err := QueueIDFromBytes(data)
require.NoError(t, err)

assert.Equal(t, tt.queueID, deserialized)
})
}
}

func TestQueueIDFromBytes_InvalidJSON(t *testing.T) {
_, err := QueueIDFromBytes([]byte(`{"invalid": json"}`))
assert.Error(t, err)
}

func TestQueueIDFromBytes_EmptyJSON(t *testing.T) {
queueID, err := QueueIDFromBytes([]byte(`{}`))
require.NoError(t, err)

assert.Empty(t, queueID.Name)
}

func TestQueueIDFromBytes_EmptyBytes(t *testing.T) {
_, err := QueueIDFromBytes([]byte{})
assert.Error(t, err)
}

func TestQueueIDFromBytes_NilBytes(t *testing.T) {
_, err := QueueIDFromBytes(nil)
assert.Error(t, err)
}
Loading
Loading