go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/engine/policy/logarithmic_batching_test.go (about) 1 // Copyright 2018 The LUCI Authors. 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 policy 16 17 import ( 18 "fmt" 19 "sort" 20 "strings" 21 "testing" 22 "time" 23 24 "go.chromium.org/luci/common/clock/testclock" 25 26 "go.chromium.org/luci/scheduler/appengine/internal" 27 "go.chromium.org/luci/scheduler/appengine/task" 28 29 . "github.com/smartystreets/goconvey/convey" 30 ) 31 32 func TestLogarithmicBatching(t *testing.T) { 33 t.Parallel() 34 35 Convey("With simulator", t, func(c C) { 36 invocationDuration := time.Hour // may be modified in tests below. 37 s := Simulator{ 38 OnRequest: func(s *Simulator, r task.Request) time.Duration { 39 return invocationDuration 40 }, 41 OnDebugLog: func(format string, args ...any) { 42 c.Printf(format+"\n", args...) 43 }, 44 } 45 46 const noDelay = time.Duration(0) 47 lastAddedTrigger := 0 48 addTriggers := func(delay time.Duration, n int) { 49 ts := make([]internal.Trigger, n) 50 for i := range ts { 51 lastAddedTrigger++ 52 ts[i] = internal.NoopTrigger( 53 fmt.Sprintf("t-%03d", lastAddedTrigger), 54 fmt.Sprintf("data-%03d", lastAddedTrigger), 55 ) 56 } 57 s.AddTrigger(delay, ts...) 58 } 59 60 var err error 61 62 Convey("Logarithm base must be at least 1.0001", func() { 63 s.Policy, err = LogarithmicBatchingPolicy(2, 1000, 1.0) 64 So(err, ShouldNotBeNil) 65 }) 66 67 Convey("Logarithmic batching works", func() { 68 // A policy that allows 1 concurrent invocation with effectively unlimited 69 // batch size and logarithm base of 2. 70 const maxBatchSize = 5 71 s.Policy, err = LogarithmicBatchingPolicy(1, maxBatchSize, 2.0) 72 So(err, ShouldBeNil) 73 74 Convey("at least 1 trigger", func() { 75 // Add exactly one trigger; log(2,1) == 0. 76 addTriggers(noDelay, 1) 77 So(s.Invocations, ShouldHaveLength, 1) 78 So(s.Last().Request.TriggerIDs(), ShouldResemble, []string{"t-001"}) 79 So(s.Last().Request.StringProperty("noop_trigger_data"), ShouldEqual, "data-001") 80 So(s.PendingTriggers, ShouldHaveLength, 0) 81 So(s.DiscardedTriggers, ShouldHaveLength, 0) 82 }) 83 84 Convey("rounds down number of consumed triggers", func() { 85 addTriggers(noDelay, 3) 86 So(s.Invocations, ShouldHaveLength, 1) 87 // log(2,3) = 1.584 88 So(s.Last().Request.TriggerIDs(), ShouldResemble, []string{"t-001"}) 89 So(s.Last().Request.StringProperty("noop_trigger_data"), ShouldEqual, "data-001") 90 So(s.PendingTriggers, ShouldHaveLength, 2) 91 So(s.DiscardedTriggers, ShouldHaveLength, 0) 92 }) 93 94 Convey("respects maxBatchSize", func() { 95 N := 1 << (maxBatchSize + 2) 96 addTriggers(noDelay, N) 97 So(s.Invocations, ShouldHaveLength, 1) 98 So(s.Last().Request.TriggerIDs(), ShouldHaveLength, maxBatchSize) 99 So(s.Last().Request.StringProperty("noop_trigger_data"), ShouldEqual, fmt.Sprintf("data-%03d", maxBatchSize)) 100 So(s.PendingTriggers, ShouldHaveLength, N-maxBatchSize) 101 So(s.DiscardedTriggers, ShouldHaveLength, 0) 102 }) 103 104 Convey("Many triggers", func() { 105 // Add 5 triggers. 106 addTriggers(noDelay, 5) 107 So(s.Invocations, ShouldHaveLength, 1) 108 So(s.Last().Request.TriggerIDs(), ShouldResemble, []string{"t-001", "t-002"}) 109 So(s.Last().Request.StringProperty("noop_trigger_data"), ShouldEqual, "data-002") 110 So(s.PendingTriggers, ShouldHaveLength, 3) 111 112 // Add a few triggers while the invocation is running. 113 addTriggers(invocationDuration/4, 2) 114 addTriggers(invocationDuration/4, 2) 115 addTriggers(invocationDuration/4, 2) 116 addTriggers(invocationDuration/4, 2) 117 // Invocation is finsihed now, we have 11 = (3 old + 4*2 new triggers). 118 So(s.Invocations, ShouldHaveLength, 2) // new invocation created. 119 // log(2,11) = 3.459 120 So(s.Last().Request.TriggerIDs(), ShouldResemble, []string{"t-003", "t-004", "t-005"}) 121 So(s.DiscardedTriggers, ShouldHaveLength, 0) 122 }) 123 }) 124 125 Convey("Long simulation", func() { 126 // Run this with: `go test -run TestLogarithmicBatching -v` 127 // TODO(tandrii): maybe make it like a Go benchmark? 128 129 // Parameters. 130 const ( 131 veryVerbose = false 132 maxBatchSize = 50 133 maxConcurrentBuilds = 2 134 logBase = 1.185 135 buildDuration = 43 * time.Minute 136 simulDuration = time.Hour * 24 * 5 137 ) 138 percentiles := []int{0, 25, 50, 75, 100} 139 // Value of 10 below means a commit lands every 10 minutes. 140 MinutesBetweenCommitsByHourOfDay := []int{ 141 20, 30, 30, 30, 30, 30, // midnight .. 6am 142 20, 10, 6, 3, 1, 1, // 6am..noon 143 1, 1, 2, 3, 4, 5, // noon .. 6pm 144 6, 10, 12, 15, 20, 20, // 6pm .. midnight 145 } 146 147 // Setup. 148 invocationDuration = buildDuration 149 s.Policy, err = LogarithmicBatchingPolicy(maxConcurrentBuilds, maxBatchSize, logBase) 150 So(err, ShouldBeNil) 151 s.Epoch = testclock.TestRecentTimeUTC.Truncate(24 * time.Hour) 152 153 // Simulate. 154 var pendingSizes []int 155 var commits []time.Time 156 for { 157 delay := time.Minute * time.Duration(MinutesBetweenCommitsByHourOfDay[s.Now.Hour()]) 158 if delay <= 0 { 159 panic("wrong minutesOfCommitDelayByHourOfDay") 160 } 161 addTriggers(delay, 1) 162 commits = append(commits, s.Now) 163 pendingSizes = append(pendingSizes, len(s.PendingTriggers)) 164 165 elapsed := s.Now.Sub(s.Epoch) 166 if veryVerbose { 167 s.OnDebugLog("%70s [%12s] [%s] remaining %d", "", elapsed, s.Now, len(s.PendingTriggers)) 168 } 169 if elapsed > simulDuration { 170 break 171 } 172 } 173 174 // There should never be any discarded triggers with this policy. 175 So(s.DiscardedTriggers, ShouldHaveLength, 0) 176 177 // Analyze. 178 var oldestCommitAgeMinutes []int 179 triggerSizes := make([]int, len(s.Invocations)) 180 commistProcesssed := 0 181 for i, inv := range s.Invocations { 182 l := len(inv.Request.IncomingTriggers) 183 oldestCreatedAt := commits[commistProcesssed] 184 oldestAge := s.Epoch.Add(inv.Created).Sub(oldestCreatedAt) 185 commistProcesssed += l 186 triggerSizes[i] = l 187 oldestCommitAgeMinutes = append(oldestCommitAgeMinutes, int(oldestAge/time.Minute)) 188 } 189 190 separatorLine := strings.Repeat("=", 80) 191 _, _ = Printf("\n\n%s\nReport\n%s\n", separatorLine, separatorLine) 192 _, _ = Printf(" * logBase %.5f\n", logBase) 193 _, _ = Printf(" * maxBatchSize %d\n", maxBatchSize) 194 _, _ = Printf(" * maxConcurrentBuilds %d\n", maxConcurrentBuilds) 195 _, _ = Printf(" * build duration %s\n", buildDuration) 196 _, _ = Printf(" * simulation duration %s\n", simulDuration) 197 _, _ = Println() 198 _, _ = Printf("Simulated %d commits, %d builds, %d triggers remaining\n", lastAddedTrigger, len(s.Invocations), len(s.PendingTriggers)) 199 _, _ = Println() 200 _, _ = Println(fmtPercentiles(" number of per-build commits", "%3d", triggerSizes, percentiles...)) 201 _, _ = Println(fmtPercentiles(" number of pending commits", "%3d", pendingSizes, percentiles...)) 202 _, _ = Println(fmtPercentiles("oldest pending commit (minutes)", "%3d", oldestCommitAgeMinutes, percentiles...)) 203 _, _ = Printf("%s\n", separatorLine) 204 }) 205 }) 206 } 207 208 func fmtPercentiles(prefix, valFormat string, values []int, percentiles ...int) string { 209 var sb strings.Builder 210 sb.WriteString(prefix) 211 sb.WriteRune(':') 212 213 sort.Ints(values) 214 sort.Ints(percentiles) 215 l := len(values) 216 for _, p := range percentiles { 217 switch { 218 case p < 0 || p > 100: 219 panic(fmt.Errorf("invalid percentile %d, must be in 0..100", p)) 220 case p == 0: 221 sb.WriteString(" min ") 222 case p == 100: 223 sb.WriteString(" max ") 224 default: 225 _, _ = fmt.Fprintf(&sb, " p%02d ", p) 226 } 227 idx := l * p / 100 228 if idx >= l { 229 idx = l - 1 230 } 231 _, _ = fmt.Fprintf(&sb, valFormat, values[idx]) 232 sb.WriteRune(',') 233 } 234 235 return strings.TrimRight(sb.String(), ",") 236 }