github.com/onflow/flow-go@v0.33.17/consensus/hotstuff/cruisectl/transition_time.go (about) 1 package cruisectl 2 3 import ( 4 "fmt" 5 "strings" 6 "time" 7 ) 8 9 // weekdays is a lookup from canonical weekday strings to the time package constant. 10 var weekdays = map[string]time.Weekday{ 11 strings.ToLower(time.Sunday.String()): time.Sunday, 12 strings.ToLower(time.Monday.String()): time.Monday, 13 strings.ToLower(time.Tuesday.String()): time.Tuesday, 14 strings.ToLower(time.Wednesday.String()): time.Wednesday, 15 strings.ToLower(time.Thursday.String()): time.Thursday, 16 strings.ToLower(time.Friday.String()): time.Friday, 17 strings.ToLower(time.Saturday.String()): time.Saturday, 18 } 19 20 // epochLength is the length of an epoch (7 days, or 1 week). 21 const epochLength = time.Hour * 24 * 7 22 23 var transitionFmt = "%s@%02d:%02d" // example: wednesday@08:00 24 25 // EpochTransitionTime represents the target epoch transition time. 26 // Epochs last one week, so the transition is defined in terms of a day-of-week and time-of-day. 27 // The target time is always in UTC to avoid confusion resulting from different 28 // representations of the same transition time and around daylight savings time. 29 type EpochTransitionTime struct { 30 day time.Weekday // day of every week to target epoch transition 31 hour uint8 // hour of the day to target epoch transition 32 minute uint8 // minute of the hour to target epoch transition 33 } 34 35 // DefaultEpochTransitionTime is the default epoch transition target. 36 // The target switchover is Wednesday 12:00 PDT, which is 19:00 UTC. 37 // The string representation is `wednesday@19:00`. 38 func DefaultEpochTransitionTime() EpochTransitionTime { 39 return EpochTransitionTime{ 40 day: time.Wednesday, 41 hour: 19, 42 minute: 0, 43 } 44 } 45 46 // String returns the canonical string representation of the transition time. 47 // This is the format expected as user input, when this value is configured manually. 48 // See ParseSwitchover for details of the format. 49 func (tt *EpochTransitionTime) String() string { 50 return fmt.Sprintf(transitionFmt, strings.ToLower(tt.day.String()), tt.hour, tt.minute) 51 } 52 53 // newInvalidTransitionStrError returns an informational error about an invalid transition string. 54 func newInvalidTransitionStrError(s string, msg string, args ...any) error { 55 args = append([]any{s}, args...) 56 return fmt.Errorf("invalid transition string (%s): "+msg, args...) 57 } 58 59 // ParseTransition parses a transition time string. 60 // A transition string must be specified according to the format: 61 // 62 // WD@HH:MM 63 // 64 // WD is the weekday string as defined by `strings.ToLower(time.Weekday.String)` 65 // HH is the 2-character hour of day, in the range [00-23] 66 // MM is the 2-character minute of hour, in the range [00-59] 67 // All times are in UTC. 68 // 69 // A generic error is returned if the input is an invalid transition string. 70 func ParseTransition(s string) (*EpochTransitionTime, error) { 71 strs := strings.Split(s, "@") 72 if len(strs) != 2 { 73 return nil, newInvalidTransitionStrError(s, "split on @ yielded %d substrings - expected %d", len(strs), 2) 74 } 75 dayStr := strs[0] 76 timeStr := strs[1] 77 if len(timeStr) != 5 || timeStr[2] != ':' { 78 return nil, newInvalidTransitionStrError(s, "time part must have form HH:MM") 79 } 80 81 var hour uint8 82 _, err := fmt.Sscanf(timeStr[0:2], "%02d", &hour) 83 if err != nil { 84 return nil, newInvalidTransitionStrError(s, "error scanning hour part: %w", err) 85 } 86 var minute uint8 87 _, err = fmt.Sscanf(timeStr[3:5], "%02d", &minute) 88 if err != nil { 89 return nil, newInvalidTransitionStrError(s, "error scanning minute part: %w", err) 90 } 91 92 day, ok := weekdays[strings.ToLower(dayStr)] 93 if !ok { 94 return nil, newInvalidTransitionStrError(s, "invalid weekday part %s", dayStr) 95 } 96 if hour > 23 { 97 return nil, newInvalidTransitionStrError(s, "invalid hour part: %d>23", hour) 98 } 99 if minute > 59 { 100 return nil, newInvalidTransitionStrError(s, "invalid minute part: %d>59", hour) 101 } 102 103 return &EpochTransitionTime{ 104 day: day, 105 hour: hour, 106 minute: minute, 107 }, nil 108 } 109 110 // inferTargetEndTime infers the target end time for the current epoch, based on 111 // the current progress through the epoch and the current time. 112 // We do this in 3 steps: 113 // 1. find the 3 candidate target end times nearest to the current time. 114 // 2. compute the estimated end time for the current epoch. 115 // 3. select the candidate target end time which is nearest to the estimated end time. 116 // 117 // NOTE 1: This method is effective only if the node's local notion of current view and 118 // time are accurate. If a node is, for example, catching up from a very old state, it 119 // will infer incorrect target end times. Since catching-up nodes don't produce usable 120 // proposals, this is OK. 121 // NOTE 2: In the long run, the target end time should be specified by the smart contract 122 // and stored along with the other protocol.Epoch information. This would remove the 123 // need for this imperfect inference logic. 124 func (tt *EpochTransitionTime) inferTargetEndTime(curTime time.Time, epochFractionComplete float64) time.Time { 125 now := curTime.UTC() 126 // find the nearest target end time, plus the targets one week before and after 127 nearestTargetDate := tt.findNearestTargetTime(now) 128 earlierTargetDate := nearestTargetDate.AddDate(0, 0, -7) 129 laterTargetDate := nearestTargetDate.AddDate(0, 0, 7) 130 131 estimatedTimeRemainingInEpoch := time.Duration((1.0 - epochFractionComplete) * float64(epochLength)) 132 estimatedEpochEndTime := now.Add(estimatedTimeRemainingInEpoch) 133 134 minDiff := estimatedEpochEndTime.Sub(nearestTargetDate).Abs() 135 inferredTargetEndTime := nearestTargetDate 136 for _, date := range []time.Time{earlierTargetDate, laterTargetDate} { 137 // compare estimate to actual based on the target 138 diff := estimatedEpochEndTime.Sub(date).Abs() 139 if diff < minDiff { 140 minDiff = diff 141 inferredTargetEndTime = date 142 } 143 } 144 145 return inferredTargetEndTime 146 } 147 148 // findNearestTargetTime interprets ref as a date (ignores time-of-day portion) 149 // and finds the nearest date, either before or after ref, which has the given weekday. 150 // We then return a time.Time with this date and the hour/minute specified by the EpochTransitionTime. 151 func (tt *EpochTransitionTime) findNearestTargetTime(ref time.Time) time.Time { 152 ref = ref.UTC() 153 hour := int(tt.hour) 154 minute := int(tt.minute) 155 date := time.Date(ref.Year(), ref.Month(), ref.Day(), hour, minute, 0, 0, time.UTC) 156 157 // walk back and forth by date around the reference until we find the closest matching weekday 158 walk := 0 159 for date.Weekday() != tt.day || date.Sub(ref).Abs().Hours() > float64(24*7/2) { 160 walk++ 161 if walk%2 == 0 { 162 date = date.AddDate(0, 0, walk) 163 } else { 164 date = date.AddDate(0, 0, -walk) 165 } 166 // sanity check to avoid an infinite loop: should be impossible 167 if walk > 14 { 168 panic(fmt.Sprintf("unexpected failure to find nearest target time with ref=%s, transition=%s", ref.String(), tt.String())) 169 } 170 } 171 return date 172 }