github.com/grailbio/base@v0.0.11/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 }