go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/engine/policy/simulator.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  	"container/heap"
    19  	"time"
    20  
    21  	"github.com/golang/protobuf/proto"
    22  	"google.golang.org/protobuf/types/known/timestamppb"
    23  
    24  	"go.chromium.org/luci/common/data/stringset"
    25  
    26  	"go.chromium.org/luci/scheduler/appengine/internal"
    27  	"go.chromium.org/luci/scheduler/appengine/task"
    28  )
    29  
    30  // Simulator is used to test policies.
    31  //
    32  // It simulates the scheduler engine logic and passage of time. It takes a
    33  // stream of triggers as input, passes them through the policy under the test,
    34  // and collects the resulting invocation requests.
    35  type Simulator struct {
    36  	// Policy is the policy function under test.
    37  	//
    38  	// Must be set by the caller.
    39  	Policy Func
    40  
    41  	// OnRequest is called whenever a new invocation request is emitted by the
    42  	// policy.
    43  	//
    44  	// It decides for how long the invocation will run.
    45  	//
    46  	// Must be set by the caller.
    47  	OnRequest func(s *Simulator, r task.Request) time.Duration
    48  
    49  	// OnDebugLog is called whenever the triggering policy logs something.
    50  	//
    51  	// May be set by the caller to collect the policy logs.
    52  	OnDebugLog func(format string, args ...any)
    53  
    54  	// Epoch is the timestamp of when the simulation started.
    55  	//
    56  	// Used to calculate SimulatedInvocation.Created. It is fine to leave it
    57  	// default if you aren't looking at absolute times (which will be weird with
    58  	// zero epoch time).
    59  	Epoch time.Time
    60  
    61  	// Now is the current time inside the simulation.
    62  	//
    63  	// It is advanced on various events (like new triggers or finishing
    64  	// invocations). Use AdvanceTime to move it manually.
    65  	Now time.Time
    66  
    67  	// PendingTriggers is a set of currently pending triggers, sorted by time
    68  	// (most recent last).
    69  	//
    70  	// Do not modify this list directly, use AddTrigger instead.
    71  	PendingTriggers []*internal.Trigger
    72  
    73  	// Invocations is a log of all produced invocations.
    74  	//
    75  	// They are ordered by the creation time. Contains invocations that are still
    76  	// running (based on Now). Use Last() as a shortcut to get the last item of
    77  	// this list.
    78  	Invocations []*SimulatedInvocation
    79  
    80  	// DiscardedTriggers is a log of all triggers that were discarded, sorted by time
    81  	// (most recent last).
    82  	DiscardedTriggers []*internal.Trigger
    83  
    84  	// Internals.
    85  
    86  	// events is a priority queue (heap) of future events.
    87  	events events
    88  	// seenTriggers is a set of IDs of all triggers ever seen, for deduplication.
    89  	seenTriggers stringset.Set
    90  	// nextInvID is used by handleRequest.
    91  	nextInvID int64
    92  	// invIDs is a set of running invocations.
    93  	invIDs map[int64]*SimulatedInvocation
    94  }
    95  
    96  // SimulatedInvocation contains details of an invocation.
    97  type SimulatedInvocation struct {
    98  	// Request is the original invocation request as emitted by the policy.
    99  	Request task.Request
   100  	// Created is when the invocation was created, relative to the epoch.
   101  	Created time.Duration
   102  	// Duration of the invocation, as returned by OnRequest.
   103  	Duration time.Duration
   104  	// Running is true if the invocation is still running.
   105  	Running bool
   106  }
   107  
   108  // SimulatedEnvironment implements Environment interface for use by Simulator.
   109  type SimulatedEnvironment struct {
   110  	OnDebugLog func(format string, args ...any)
   111  }
   112  
   113  // DebugLog is part of Environment interface.
   114  func (s *SimulatedEnvironment) DebugLog(format string, args ...any) {
   115  	if s.OnDebugLog != nil {
   116  		s.OnDebugLog(format, args...)
   117  	}
   118  }
   119  
   120  ////////////////////////////////////////////////////////////////////////////////
   121  // Triggering and invocations.
   122  
   123  // Last returns the last invocation in Invocations list or nil if its empty.
   124  func (s *Simulator) Last() *SimulatedInvocation {
   125  	if len(s.Invocations) == 0 {
   126  		return nil
   127  	}
   128  	return s.Invocations[len(s.Invocations)-1]
   129  }
   130  
   131  // AddTrigger submits a trigger (one or many) to the pending trigger set.
   132  //
   133  // This causes the execution of the policy function to decide what to do with
   134  // the new triggers.
   135  //
   136  // 'delay' is time interval from the previously submitted trigger. It is used to
   137  // advance time. The current simulation time will be used to populate trigger's
   138  // Created field.
   139  func (s *Simulator) AddTrigger(delay time.Duration, t ...internal.Trigger) {
   140  	s.AdvanceTime(delay)
   141  
   142  	if s.seenTriggers == nil {
   143  		s.seenTriggers = stringset.New(0)
   144  	}
   145  
   146  	ts := timestamppb.New(s.Now)
   147  	for _, tr := range t {
   148  		tr := proto.Clone(&tr).(*internal.Trigger)
   149  		tr.Created = ts
   150  		if s.seenTriggers.Add(tr.Id) {
   151  			s.PendingTriggers = append(s.PendingTriggers, tr)
   152  		}
   153  	}
   154  
   155  	s.triage()
   156  }
   157  
   158  // triage executes the triggering policy function.
   159  func (s *Simulator) triage() {
   160  	// Collect the unordered list of currently running invocations.
   161  	invs := make([]int64, 0, len(s.invIDs))
   162  	for id := range s.invIDs {
   163  		invs = append(invs, id)
   164  	}
   165  
   166  	// Clone pending triggers list since we don't want the policy to mutate them.
   167  	triggers := make([]*internal.Trigger, len(s.PendingTriggers))
   168  	for i, t := range s.PendingTriggers {
   169  		triggers[i] = proto.Clone(t).(*internal.Trigger)
   170  	}
   171  
   172  	// Execute the policy function, collecting its log.
   173  	out := s.Policy(&SimulatedEnvironment{s.OnDebugLog}, In{
   174  		Now:               s.Now,
   175  		ActiveInvocations: invs,
   176  		Triggers:          triggers,
   177  	})
   178  
   179  	// Instantiate all new invocations and collect a set of consumed triggers.
   180  	consumed := stringset.New(0)
   181  	for _, r := range out.Requests {
   182  		s.handleRequest(r)
   183  		for _, t := range r.IncomingTriggers {
   184  			consumed.Add(t.Id)
   185  		}
   186  	}
   187  
   188  	// Collect a set of discarded triggers.
   189  	discarded := stringset.New(0)
   190  	for _, t := range out.Discard {
   191  		discarded.Add(t.Id)
   192  	}
   193  
   194  	// Pop all consumed or discarded triggers from PendingTriggers list (keeping it sorted).
   195  	if consumed.Len() != 0 || discarded.Len() != 0 {
   196  		filtered := make([]*internal.Trigger, 0, len(s.PendingTriggers))
   197  		for _, t := range s.PendingTriggers {
   198  			if !consumed.Has(t.Id) && !discarded.Has(t.Id) {
   199  				filtered = append(filtered, t)
   200  			}
   201  			if discarded.Has(t.Id) {
   202  				s.DiscardedTriggers = append(s.DiscardedTriggers, t)
   203  			}
   204  		}
   205  		s.PendingTriggers = filtered
   206  	}
   207  }
   208  
   209  // handleRequest is called for each invocation request created by the policy.
   210  //
   211  // It adds new SimulatedInvocation to Invocations list.
   212  func (s *Simulator) handleRequest(r task.Request) {
   213  	dur := s.OnRequest(s, r)
   214  	if dur <= 0 {
   215  		panic("the invocation duration should be positive")
   216  	}
   217  
   218  	inv := &SimulatedInvocation{
   219  		Request:  r,
   220  		Created:  s.Now.Sub(s.Epoch),
   221  		Duration: dur,
   222  		Running:  true,
   223  	}
   224  	s.Invocations = append(s.Invocations, inv)
   225  
   226  	s.nextInvID++
   227  	id := s.nextInvID
   228  	if s.invIDs == nil {
   229  		s.invIDs = map[int64]*SimulatedInvocation{}
   230  	}
   231  	s.invIDs[id] = inv
   232  
   233  	s.scheduleEvent(event{
   234  		eta: s.Now.Add(inv.Duration),
   235  		cb: func() {
   236  			// On invocation completion, kick it from the active invocations set and
   237  			// rerun the triggering policy function to decide what to do next.
   238  			inv.Running = false
   239  			delete(s.invIDs, id)
   240  			s.triage()
   241  		},
   242  	})
   243  }
   244  
   245  ////////////////////////////////////////////////////////////////////////////////
   246  // Event reactor.
   247  
   248  // event sits in a timeline and its callback is executed at moment 'eta'.
   249  type event struct {
   250  	eta time.Time
   251  	cb  func()
   252  }
   253  
   254  // events implements heap.Interface, smallest eta is on top of the heap.
   255  type events []event
   256  
   257  func (e events) Len() int           { return len(e) }
   258  func (e events) Less(i, j int) bool { return e[i].eta.Before(e[j].eta) }
   259  func (e events) Swap(i, j int)      { e[i], e[j] = e[j], e[i] }
   260  func (e *events) Push(x any)        { *e = append(*e, x.(event)) }
   261  func (e *events) Pop() any {
   262  	old := *e
   263  	n := len(old)
   264  	x := old[n-1]
   265  	*e = old[0 : n-1]
   266  	return x
   267  }
   268  
   269  // AdvanceTime moves the simulated time, executing all events that happen.
   270  func (s *Simulator) AdvanceTime(d time.Duration) {
   271  	switch {
   272  	case d == 0:
   273  		return
   274  	case d < 0:
   275  		panic("time must move forward only")
   276  	}
   277  
   278  	// First tick ever? Reset Now to Epoch, since Epoch is our beginning of times.
   279  	if s.Now.IsZero() {
   280  		s.Now = s.Epoch
   281  	}
   282  
   283  	deadline := s.Now.Add(d)
   284  	for {
   285  		// Nothing is happening at all or events happen later than we wish to go?
   286  		if ev := s.peekEvent(); ev == nil || ev.eta.After(deadline) {
   287  			s.Now = deadline
   288  			return
   289  		}
   290  
   291  		// Advance the time to the point when the event is happening and execute the
   292  		// event's callback. It may result in most stuff added to the timeline which
   293  		// we will discover on the next iteration of the loop.
   294  		ev := s.popEvent()
   295  		s.Now = ev.eta
   296  		ev.cb()
   297  	}
   298  }
   299  
   300  // scheduleEvent adds an event to the event queue.
   301  //
   302  // Panics if event's ETA is not in the future.
   303  func (s *Simulator) scheduleEvent(e event) {
   304  	if !e.eta.After(s.Now) {
   305  		panic("event's ETA should be in the future")
   306  	}
   307  	heap.Push(&s.events, e)
   308  }
   309  
   310  // peekEvent peeks at the event that happens next.
   311  //
   312  // Returns nil if there are no pending events.
   313  func (s *Simulator) peekEvent() *event {
   314  	if len(s.events) == 0 {
   315  		return nil
   316  	}
   317  	return &s.events[0]
   318  }
   319  
   320  // popEvent removes the event that happens next.
   321  //
   322  // Panics if there's no pending events.
   323  func (s *Simulator) popEvent() event {
   324  	if len(s.events) == 0 {
   325  		panic("no events to pop")
   326  	}
   327  	return heap.Pop(&s.events).(event)
   328  }