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  }