1515package main
1616
1717import (
18+ "context"
1819 "fmt"
1920
2021 "github.com/uber-go/tally"
2122 "github.com/uber/submitqueue/submitqueue/core/changeset"
23+ "github.com/uber/submitqueue/submitqueue/entity"
2224 "github.com/uber/submitqueue/submitqueue/extension/buildrunner"
2325 buildfake "github.com/uber/submitqueue/submitqueue/extension/buildrunner/fake"
2426 "github.com/uber/submitqueue/submitqueue/extension/changeprovider"
@@ -27,6 +29,14 @@ import (
2729 conflictfake "github.com/uber/submitqueue/submitqueue/extension/conflict/fake"
2830 "github.com/uber/submitqueue/submitqueue/extension/conflict/fileoverlap"
2931 "github.com/uber/submitqueue/submitqueue/extension/conflict/none"
32+ "github.com/uber/submitqueue/submitqueue/extension/scorer"
33+ "github.com/uber/submitqueue/submitqueue/extension/scorer/composite"
34+ scorerfake "github.com/uber/submitqueue/submitqueue/extension/scorer/fake"
35+ "github.com/uber/submitqueue/submitqueue/extension/scorer/heuristic"
36+ "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator/sticky"
37+ "github.com/uber/submitqueue/submitqueue/extension/speculation/generator/bestfirst"
38+ "github.com/uber/submitqueue/submitqueue/extension/speculation/speculator"
39+ specstandard "github.com/uber/submitqueue/submitqueue/extension/speculation/speculator/standard"
3040 "go.uber.org/zap"
3141)
3242
@@ -43,6 +53,15 @@ type Profile struct {
4353
4454 // Analyzer detects conflicts between concurrent batches in this queue.
4555 Analyzer conflict.Analyzer
56+
57+ // Scorer holds this queue's scoring profile. There is no scoring stage: the
58+ // scorer feeds the queue's speculator, which ranks candidate paths by how
59+ // likely their assumptions are to hold.
60+ Scorer scorer.Scorer
61+
62+ // Speculator decides which of this queue's speculation paths to build and
63+ // which running ones to preempt, within the build budget.
64+ Speculator speculator.Speculator
4665}
4766
4867// Profiles maps a queue name to its extension Profile, falling back to a
@@ -86,6 +105,14 @@ func (p Profiles) AnalyzerFactory() conflict.Factory {
86105 })
87106}
88107
108+ // SpeculatorFactory returns a speculator.Factory that resolves the Speculator
109+ // for each queue from the profile registry.
110+ func (p Profiles ) SpeculatorFactory () speculator.Factory {
111+ return speculatorFunc (func (c speculator.Config ) (speculator.Speculator , error ) {
112+ return p .For (c .QueueName ).Speculator , nil
113+ })
114+ }
115+
89116// Thin func-type adapters — the http.HandlerFunc trick applied to each
90117// extension Factory interface. Each func type satisfies the Factory contract,
91118// letting Profiles cross the host/library boundary without dedicated structs.
@@ -104,37 +131,68 @@ type analyzerFunc func(conflict.Config) (conflict.Analyzer, error)
104131
105132func (f analyzerFunc ) For (c conflict.Config ) (conflict.Analyzer , error ) { return f (c ) }
106133
134+ type speculatorFunc func (speculator.Config ) (speculator.Speculator , error )
135+
136+ func (f speculatorFunc ) For (c speculator.Config ) (speculator.Speculator , error ) { return f (c ) }
137+
107138// newProfiles builds the per-queue extension profiles for the example.
108139// Edge integrations (change provider) and the build runner form a shared
109140// baseline; each per-queue profile starts from that baseline and overrides
110- // only the extensions that differ — here the conflict analyzer.
141+ // only the extensions that differ — here the conflict analyzer and the scorer .
111142// Queues without an explicit profile fall back to the baseline.
112143func newProfiles (logger * zap.Logger , scope tally.Scope , resolver changeset.Resolver ) (Profiles , error ) {
113144 cp , err := newChangeProvider (logger , scope )
114145 if err != nil {
115146 return Profiles {}, fmt .Errorf ("failed to create change provider: %w" , err )
116147 }
117148
149+ // batchLines buckets a batch by total lines changed across all its changes —
150+ // larger batches are likelier to fail to land.
151+ batchLines := func (_ context.Context , changes entity.BatchChanges ) (int , error ) {
152+ return changes .TotalLinesChanged (), nil
153+ }
154+
118155 // Baseline profile: shared edge integrations + a fake build runner (every
119- // build succeeds unless a head URI carries a failure marker). The build
120- // runner instance is shared by the build and buildsignal controllers (same
121- // profile, same instance) so a build's recorded outcome survives across
122- // their separate factory lookups.
156+ // build succeeds unless a head URI carries a failure marker), plus permissive
157+ // defaults for scorer and conflict. The build runner instance is shared by
158+ // the build and buildsignal controllers (same profile, same instance) so a
159+ // build's recorded outcome survives across their separate factory lookups.
123160 //
124- // The analyzer is wrapped by conflictfake with a nil predicate
125- // (passthrough) — swap the predicate (e.g. conflictfake.FailAlways) on a
126- // queue to exercise the analyzer error path, as e2e-conflict-error-queue
161+ // The scorer is wrapped by scorerfake so a change URI carrying
162+ // "sq-fake=score-error" forces a scoring error end-to-end; it is a pure
163+ // passthrough otherwise. The analyzer is wrapped by conflictfake with a nil
164+ // predicate (passthrough) — swap the predicate (e.g. conflictfake.FailAlways)
165+ // on a queue to exercise the analyzer error path, as e2e-conflict-error-queue
127166 // below does.
128167 base := Profile {
129168 ChangeProvider : cp ,
130169 BuildRunner : buildfake .New (resolver ),
131170 // TODO: replace the delegate with a real analyzer (e.g. Tango target
132171 // analysis). "all" serializes the queue conservatively.
133172 Analyzer : conflictfake .New (all .New (), nil ),
173+ Scorer : scorerfake .New (resolver , heuristic .New (
174+ resolver ,
175+ []heuristic.Bucket {{Min : 0 , Max : 1 << 31 - 1 , Score : 0.5 }},
176+ batchLines , scope .SubScope ("scorer.default" ),
177+ )),
134178 }
135179
180+ // test-queue: bucketed heuristic scorer; conservative (serialized) conflicts
181+ // inherited from the baseline.
182+ testQueue := base
183+ testQueue .Scorer = scorerfake .New (resolver , heuristic .New (
184+ resolver ,
185+ []heuristic.Bucket {
186+ {Min : 0 , Max : 1 , Score : 0.95 },
187+ {Min : 2 , Max : 5 , Score : 0.80 },
188+ {Min : 6 , Max : 20 , Score : 0.60 },
189+ {Min : 21 , Max : 1 << 31 - 1 , Score : 0.40 },
190+ },
191+ batchLines , scope .SubScope ("scorer.test-queue" ),
192+ ))
193+
136194 // e2e-conflict-error-queue: every conflict analysis fails, exercising the
137- // analyzer error path. Edge integrations inherit the baseline.
195+ // analyzer error path. Scorer/edge integrations inherit the baseline.
138196 conflictErrQueue := base
139197 conflictErrQueue .Analyzer = conflictfake .New (all .New (), conflictfake .FailAlways )
140198
@@ -143,16 +201,44 @@ func newProfiles(logger *zap.Logger, scope tally.Scope, resolver changeset.Resol
143201 fileOverlapQueue := base
144202 fileOverlapQueue .Analyzer = fileoverlap .New (resolver )
145203
146- // e2e-test-queue: no conflicts (maximum parallelism).
204+ // e2e-test-queue: composite scorer; no conflicts (maximum parallelism).
147205 e2eQueue := base
148206 e2eQueue .Analyzer = conflictfake .New (none .New (), nil )
207+ e2eQueue .Scorer = scorerfake .New (resolver , composite .New (
208+ map [string ]scorer.Scorer {
209+ "size" : heuristic .New (resolver , []heuristic.Bucket {{Min : 0 , Max : 1 << 31 - 1 , Score : 0.8 }}, batchLines , scope ),
210+ "flat" : heuristic .New (resolver , []heuristic.Bucket {{Min : 0 , Max : 1 << 31 - 1 , Score : 0.6 }}, batchLines , scope ),
211+ },
212+ composite .Avg , scope .SubScope ("scorer.e2e-test-queue" ),
213+ ))
149214
215+ // The speculator is composed last, because it is built from whatever scorer
216+ // the profile ended up with.
150217 return Profiles {
151- defaultProfile : base ,
218+ defaultProfile : withSpeculator ( base ) ,
152219 byQueue : map [string ]Profile {
153- "e2e-test-queue" : e2eQueue ,
154- "e2e-conflict-error-queue" : conflictErrQueue ,
155- "file-overlap-queue" : fileOverlapQueue ,
220+ "test-queue" : withSpeculator (testQueue ),
221+ "e2e-test-queue" : withSpeculator (e2eQueue ),
222+ "e2e-conflict-error-queue" : withSpeculator (conflictErrQueue ),
223+ "file-overlap-queue" : withSpeculator (fileOverlapQueue ),
156224 },
157225 }, nil
158226}
227+
228+ // defaultBuildBudget caps how many builds a queue may have occupying CI at
229+ // once. It is the only rationing lever the allocator has.
230+ //
231+ // TODO: move this onto entity.QueueConfig so operators can tune it per queue
232+ // without a code change. QueueConfig carries only the queue name today.
233+ const defaultBuildBudget = 4
234+
235+ // withSpeculator returns the profile with its speculator composed from its own
236+ // scorer: bestfirst ranks a queue's candidate paths by how likely all their
237+ // assumptions are to hold, and sticky spends the build budget down that ranking
238+ // without preempting builds already running. Swapping either part changes the
239+ // policy without touching the speculate controller, which depends only on the
240+ // Speculator contract.
241+ func withSpeculator (p Profile ) Profile {
242+ p .Speculator = specstandard .New (bestfirst .New (p .Scorer ), sticky .New (defaultBuildBudget ))
243+ return p
244+ }
0 commit comments