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  }