github.com/Schaudge/grailbase@v0.0.0-20240223061707-44c758a471c0/cloud/spotfeed/querier.go (about)

     1  package spotfeed
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"sort"
     7  	"time"
     8  )
     9  
    10  // ErrMissingData is the error returned if there is no data for the query time period.
    11  var ErrMissingData = fmt.Errorf("missing data")
    12  
    13  // Period is a time period with a start and end time.
    14  type Period struct {
    15  	Start, End time.Time
    16  }
    17  
    18  type Cost struct {
    19  	// Period defines the time period for which this cost is applicable.
    20  	Period
    21  	// ChargeUSD is the total charge over the time period specified by Period.
    22  	ChargeUSD float64
    23  }
    24  
    25  // Querier provides the ability to query for costs.
    26  type Querier interface {
    27  
    28  	// Query computes the cost charged for the given instanceId for given time period
    29  	// assuming that terminated was the time at which the instance was terminated.
    30  	//
    31  	// It is not required to specify terminated time.  Specifying it only impacts cost
    32  	// calculations for a time period that overlaps the last partial hour of the instance's lifetime.
    33  	//
    34  	// For example, if the instance was running only for say 30m in the last partial hour, and if the
    35  	// desired time period overlaps say the first 15m of that hour, then one must specify
    36  	// terminated time to compute the cost correctly.  In this example, not specifying terminated time
    37  	// would result in a cost higher than actual (ie for the entire last 30 mins instead of only 15 mins).
    38  	//
    39  	// If the given time period spans beyond the instance's actual lifetime, the returned cost will
    40  	// yet only reflect the lifetime cost.  While the returned cost will have the correct start time,
    41  	// the correct end time will be set only if terminated time is provided.
    42  	//
    43  	// Query will return ErrMissingData if it has no data for the given instanceId or
    44  	// if it doesn't have data overlapping the given time period.
    45  	Query(instanceId string, p Period, terminated time.Time) (Cost, error)
    46  }
    47  
    48  // NewQuerier fetches data from the given loader and returns a Querier based on the returned data.
    49  func NewQuerier(ctx context.Context, l Loader) (Querier, error) {
    50  	entries, err := l.Fetch(ctx, false)
    51  	if err != nil {
    52  		return nil, err
    53  	}
    54  	return newQuerier(entries), nil
    55  }
    56  
    57  // querier provides a Querier implementation using static list of entries provided upon initialization.
    58  type querier struct {
    59  	byInstanceId map[string][]*Entry
    60  }
    61  
    62  func newQuerier(all []*Entry) *querier {
    63  	byInstanceId := make(map[string][]*Entry)
    64  	for _, entry := range all {
    65  		iid := entry.InstanceID
    66  		if _, ok := byInstanceId[iid]; !ok {
    67  			byInstanceId[iid] = []*Entry{}
    68  		}
    69  		byInstanceId[iid] = append(byInstanceId[iid], entry)
    70  	}
    71  	for iid, entries := range byInstanceId {
    72  		// For each instance, first sort all the entries
    73  		sort.Slice(entries, func(i, j int) bool {
    74  			return entries[i].Timestamp.Before(entries[j].Timestamp)
    75  		})
    76  		var (
    77  			prev       *Entry
    78  			iidEntries []*Entry
    79  		)
    80  		// There can be multiple entries at the same Timestamp, one for each Version.
    81  		// We simply take the entry of the version that has the max cost for the same timestamp.
    82  		for _, entry := range entries {
    83  			switch {
    84  			case prev == nil:
    85  			case prev.Timestamp != entry.Timestamp:
    86  				iidEntries = append(iidEntries, prev)
    87  			case entry.ChargeUSD > prev.ChargeUSD:
    88  			default:
    89  				continue // Keep prev as-is.
    90  			}
    91  			prev = entry
    92  		}
    93  		if prev != nil {
    94  			iidEntries = append(iidEntries, prev)
    95  		}
    96  		byInstanceId[iid] = iidEntries
    97  	}
    98  	return &querier{byInstanceId: byInstanceId}
    99  }
   100  
   101  // Query implements Querier interface.
   102  func (q *querier) Query(instanceId string, p Period, terminated time.Time) (Cost, error) {
   103  	p.Start, p.End = p.Start.Truncate(time.Second), p.End.Truncate(time.Second)
   104  	entries, ok := q.byInstanceId[instanceId]
   105  	if !ok || len(entries) == 0 {
   106  		return Cost{}, ErrMissingData
   107  	}
   108  	i := sort.Search(len(entries), func(i int) bool {
   109  		// This will return an entry after p.Start, even if one exists exactly at p.Start
   110  		return p.Start.Before(entries[i].Timestamp)
   111  	})
   112  	switch {
   113  	case i == len(entries):
   114  		// Start is past all entries, so we don't have any data for the given time period.
   115  		return Cost{}, ErrMissingData
   116  	case i == 0 && entries[i].Timestamp.After(p.End):
   117  		// End is before the first entry, so we don't have any data for the given time period.
   118  		return Cost{}, ErrMissingData
   119  	case i > 0:
   120  		// Since we always get the entry after p.Start, we have to move back (if possible)
   121  		// to cover the time period starting from p.Start.
   122  		i--
   123  	}
   124  	var (
   125  		ended bool
   126  		cost  = Cost{Period: Period{End: p.End}}
   127  		prev  = entries[i]
   128  	)
   129  	if startTs := entries[i].Timestamp; p.Start.After(startTs) {
   130  		cost.Start = p.Start
   131  	} else {
   132  		cost.Start = startTs
   133  	}
   134  	for i++; !ended && i < len(entries); i++ {
   135  		startTs := prev.Timestamp
   136  		endTs := entries[i].Timestamp
   137  		if p.Start.After(startTs) {
   138  			startTs = p.Start
   139  		}
   140  		if p.End.Before(endTs) {
   141  			ended = true
   142  			endTs = p.End
   143  		}
   144  		ratio := endTs.Sub(startTs).Seconds() / entries[i].Timestamp.Sub(prev.Timestamp).Seconds()
   145  		cost.ChargeUSD += ratio * prev.ChargeUSD
   146  		prev = entries[i]
   147  	}
   148  	if !ended {
   149  		ratio := 1.0
   150  		switch {
   151  		case terminated.IsZero():
   152  		case p.End.Before(terminated):
   153  			ratio = p.End.Sub(prev.Timestamp).Seconds() / terminated.Sub(prev.Timestamp).Seconds()
   154  		default:
   155  			cost.End = terminated
   156  		}
   157  		cost.ChargeUSD += ratio * prev.ChargeUSD
   158  	}
   159  	return cost, nil
   160  }