diff --git a/go.mod b/go.mod index 3caf9c4ca..0f1465551 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 ) -require github.com/go-co-op/gocron/v2 v2.5.0 +require github.com/go-co-op/gocron/v2 v2.12.4 require ( github.com/emicklei/go-restful/v3 v3.11.0 // indirect @@ -28,7 +28,7 @@ require ( github.com/jonboulle/clockwork v0.4.0 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/vishvananda/netns v0.0.4 // indirect - golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect golang.org/x/sync v0.8.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect ) diff --git a/go.sum b/go.sum index 7ab72d4e2..26c83e001 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-co-op/gocron/v2 v2.5.0 h1:ff/TJX9GdTJBDL1il9cyd/Sj3WnS+BB7ZzwHKSNL5p8= -github.com/go-co-op/gocron/v2 v2.5.0/go.mod h1:ckPQw96ZuZLRUGu88vVpd9a6d9HakI14KWahFZtGvNw= +github.com/go-co-op/gocron/v2 v2.12.4 h1:h1HWApo3T+61UrZqEY2qG1LUpDnB7tkYITxf6YIK354= +github.com/go-co-op/gocron/v2 v2.12.4/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= @@ -134,8 +134,8 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY= -golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= diff --git a/vendor/github.com/go-co-op/gocron/v2/.golangci.yaml b/vendor/github.com/go-co-op/gocron/v2/.golangci.yaml index 07878d85f..9d6ae5d74 100644 --- a/vendor/github.com/go-co-op/gocron/v2/.golangci.yaml +++ b/vendor/github.com/go-co-op/gocron/v2/.golangci.yaml @@ -2,14 +2,20 @@ run: timeout: 5m issues-exit-code: 1 tests: true - skip-dirs: - - local issues: max-same-issues: 100 include: - EXC0012 - EXC0014 + exclude-dirs: + - local + exclude-rules: + - path: example_test.go + linters: + - revive + text: "seems to be unused" + fix: true linters: enable: @@ -29,21 +35,10 @@ linters: - whitespace output: - # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" - format: colored-line-number - # print lines of code with issue, default is true + formats: + - format: colored-line-number print-issued-lines: true - # print linter name in the end of issue text, default is true print-linter-name: true - # make issues output unique by line, default is true uniq-by-line: true - # add a prefix to the output file references; default is no prefix path-prefix: "" - # sorts results by: filepath, line and column sort-results: true - -linters-settings: - golint: - min-confidence: 0.8 - -fix: true diff --git a/vendor/github.com/go-co-op/gocron/v2/README.md b/vendor/github.com/go-co-op/gocron/v2/README.md index 953a1df9c..4a1de758e 100644 --- a/vendor/github.com/go-co-op/gocron/v2/README.md +++ b/vendor/github.com/go-co-op/gocron/v2/README.md @@ -98,7 +98,7 @@ Jobs can be run every x weeks on specific days of the week and at specific times - [**Monthly**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#MonthlyJob): Jobs can be run every x months on specific days of the month and at specific times. - [**One time**](https://pkg.go.dev/github.com/go-co-op/gocron/v2#OneTimeJob): -Jobs can be run once at a specific time. These are non-recurring jobs. +Jobs can be run at specific time(s) (either once or many times). ### Concurrency Limits Jobs can be limited individually or across the entire scheduler. diff --git a/vendor/github.com/go-co-op/gocron/v2/SECURITY.md b/vendor/github.com/go-co-op/gocron/v2/SECURITY.md index 654a08550..1d1d50eab 100644 --- a/vendor/github.com/go-co-op/gocron/v2/SECURITY.md +++ b/vendor/github.com/go-co-op/gocron/v2/SECURITY.md @@ -13,4 +13,4 @@ The current plan is to maintain version 2 as long as possible incorporating any Vulnerabilities can be reported by [opening an issue](https://github.com/go-co-op/gocron/issues/new/choose) or reaching out on Slack: [](https://gophers.slack.com/archives/CQ7T0T1FW) -We will do our best to addrerss any vulnerabilities in an expeditious manner. +We will do our best to address any vulnerabilities in an expeditious manner. diff --git a/vendor/github.com/go-co-op/gocron/v2/errors.go b/vendor/github.com/go-co-op/gocron/v2/errors.go index 164384357..d160860bf 100644 --- a/vendor/github.com/go-co-op/gocron/v2/errors.go +++ b/vendor/github.com/go-co-op/gocron/v2/errors.go @@ -4,10 +4,12 @@ import "fmt" // Public error definitions var ( + ErrCronJobInvalid = fmt.Errorf("gocron: CronJob: invalid crontab") ErrCronJobParse = fmt.Errorf("gocron: CronJob: crontab parse failure") ErrDailyJobAtTimeNil = fmt.Errorf("gocron: DailyJob: atTime within atTimes must not be nil") ErrDailyJobAtTimesNil = fmt.Errorf("gocron: DailyJob: atTimes must not be nil") ErrDailyJobHours = fmt.Errorf("gocron: DailyJob: atTimes hours must be between 0 and 23 inclusive") + ErrDailyJobZeroInterval = fmt.Errorf("gocron: DailyJob: interval must be greater than 0") ErrDailyJobMinutesSeconds = fmt.Errorf("gocron: DailyJob: atTimes minutes and seconds must be between 0 and 59 inclusive") ErrDurationJobIntervalZero = fmt.Errorf("gocron: DurationJob: time interval is 0") ErrDurationRandomJobMinMax = fmt.Errorf("gocron: DurationRandomJob: minimum duration must be less than maximum duration") @@ -19,6 +21,7 @@ var ( ErrMonthlyJobAtTimesNil = fmt.Errorf("gocron: MonthlyJob: atTimes must not be nil") ErrMonthlyJobDaysNil = fmt.Errorf("gocron: MonthlyJob: daysOfTheMonth must not be nil") ErrMonthlyJobHours = fmt.Errorf("gocron: MonthlyJob: atTimes hours must be between 0 and 23 inclusive") + ErrMonthlyJobZeroInterval = fmt.Errorf("gocron: MonthlyJob: interval must be greater than 0") ErrMonthlyJobMinutesSeconds = fmt.Errorf("gocron: MonthlyJob: atTimes minutes and seconds must be between 0 and 59 inclusive") ErrNewJobTaskNil = fmt.Errorf("gocron: NewJob: Task must not be nil") ErrNewJobTaskNotFunc = fmt.Errorf("gocron: NewJob: Task.Function must be of kind reflect.Func") @@ -32,17 +35,23 @@ var ( ErrWeeklyJobAtTimesNil = fmt.Errorf("gocron: WeeklyJob: atTimes must not be nil") ErrWeeklyJobDaysOfTheWeekNil = fmt.Errorf("gocron: WeeklyJob: daysOfTheWeek must not be nil") ErrWeeklyJobHours = fmt.Errorf("gocron: WeeklyJob: atTimes hours must be between 0 and 23 inclusive") + ErrWeeklyJobZeroInterval = fmt.Errorf("gocron: WeeklyJob: interval must be greater than 0") ErrWeeklyJobMinutesSeconds = fmt.Errorf("gocron: WeeklyJob: atTimes minutes and seconds must be between 0 and 59 inclusive") + ErrPanicRecovered = fmt.Errorf("gocron: panic recovered") ErrWithClockNil = fmt.Errorf("gocron: WithClock: clock must not be nil") ErrWithDistributedElectorNil = fmt.Errorf("gocron: WithDistributedElector: elector must not be nil") ErrWithDistributedLockerNil = fmt.Errorf("gocron: WithDistributedLocker: locker must not be nil") ErrWithDistributedJobLockerNil = fmt.Errorf("gocron: WithDistributedJobLocker: locker must not be nil") + ErrWithIdentifierNil = fmt.Errorf("gocron: WithIdentifier: identifier must not be nil") ErrWithLimitConcurrentJobsZero = fmt.Errorf("gocron: WithLimitConcurrentJobs: limit must be greater than 0") ErrWithLocationNil = fmt.Errorf("gocron: WithLocation: location must not be nil") ErrWithLoggerNil = fmt.Errorf("gocron: WithLogger: logger must not be nil") ErrWithMonitorNil = fmt.Errorf("gocron: WithMonitor: monitor must not be nil") ErrWithNameEmpty = fmt.Errorf("gocron: WithName: name must not be empty") ErrWithStartDateTimePast = fmt.Errorf("gocron: WithStartDateTime: start must not be in the past") + ErrWithStopDateTimePast = fmt.Errorf("gocron: WithStopDateTime: end must not be in the past") + ErrStartTimeLaterThanEndTime = fmt.Errorf("gocron: WithStartDateTime: start must not be later than end") + ErrStopTimeEarlierThanStartTime = fmt.Errorf("gocron: WithStopDateTime: end must not be earlier than start") ErrWithStopTimeoutZeroOrNegative = fmt.Errorf("gocron: WithStopTimeout: timeout must be greater than 0") ) diff --git a/vendor/github.com/go-co-op/gocron/v2/executor.go b/vendor/github.com/go-co-op/gocron/v2/executor.go index 64e12c5a8..af4b7c985 100644 --- a/vendor/github.com/go-co-op/gocron/v2/executor.go +++ b/vendor/github.com/go-co-op/gocron/v2/executor.go @@ -2,29 +2,53 @@ package gocron import ( "context" + "fmt" "strconv" "sync" "time" + "github.com/jonboulle/clockwork" + "github.com/google/uuid" ) type executor struct { - ctx context.Context - cancel context.CancelFunc - logger Logger - stopCh chan struct{} - jobsIn chan jobIn + // context used for shutting down + ctx context.Context + // cancel used by the executor to signal a stop of it's functions + cancel context.CancelFunc + // clock used for regular time or mocking time + clock clockwork.Clock + // the executor's logger + logger Logger + + // receives jobs scheduled to execute + jobsIn chan jobIn + // sends out jobs for rescheduling jobsOutForRescheduling chan uuid.UUID - jobsOutCompleted chan uuid.UUID - jobOutRequest chan jobOutRequest - stopTimeout time.Duration - done chan error - singletonRunners *sync.Map // map[uuid.UUID]singletonRunner - limitMode *limitModeConfig - elector Elector - locker Locker - monitor Monitor + // sends out jobs once completed + jobsOutCompleted chan uuid.UUID + // used to request jobs from the scheduler + jobOutRequest chan jobOutRequest + + // used by the executor to receive a stop signal from the scheduler + stopCh chan struct{} + // the timeout value when stopping + stopTimeout time.Duration + // used to signal that the executor has completed shutdown + done chan error + + // runners for any singleton type jobs + // map[uuid.UUID]singletonRunner + singletonRunners *sync.Map + // config for limit mode + limitMode *limitModeConfig + // the elector when running distributed instances + elector Elector + // the locker when running distributed instances + locker Locker + // monitor for reporting metrics + monitor Monitor } type jobIn struct { @@ -172,7 +196,7 @@ func (e *executor) start() { default: // runner is busy, reschedule the work for later // which means we just skip it here and do nothing - // TODO when metrics are added, this should increment a rescheduled metric + e.incrementJobCounter(*j, SingletonRescheduled) e.sendOutForRescheduling(&jIn) } } else { @@ -334,6 +358,10 @@ func (e *executor) runJob(j internalJob, jIn jobIn) { default: } + if j.stopTimeReached(e.clock.Now()) { + return + } + if e.elector != nil { if err := e.elector.IsLeader(j.ctx); err != nil { e.sendOutForRescheduling(&jIn) @@ -343,6 +371,7 @@ func (e *executor) runJob(j internalJob, jIn jobIn) { } else if j.locker != nil { lock, err := j.locker.Lock(j.ctx, j.name) if err != nil { + _ = callJobFuncWithParams(j.afterLockError, j.id, j.name, err) e.sendOutForRescheduling(&jIn) e.incrementJobCounter(j, Skip) return @@ -351,6 +380,7 @@ func (e *executor) runJob(j internalJob, jIn jobIn) { } else if e.locker != nil { lock, err := e.locker.Lock(j.ctx, j.name) if err != nil { + _ = callJobFuncWithParams(j.afterLockError, j.id, j.name, err) e.sendOutForRescheduling(&jIn) e.incrementJobCounter(j, Skip) return @@ -366,10 +396,13 @@ func (e *executor) runJob(j internalJob, jIn jobIn) { } startTime := time.Now() - err := callJobFuncWithParams(j.function, j.parameters...) - if e.monitor != nil { - e.monitor.RecordJobTiming(startTime, time.Now(), j.id, j.name, j.tags) + var err error + if j.afterJobRunsWithPanic != nil { + err = e.callJobWithRecover(j) + } else { + err = callJobFuncWithParams(j.function, j.parameters...) } + e.recordJobTiming(startTime, time.Now(), j) if err != nil { _ = callJobFuncWithParams(j.afterJobRunsWithError, j.id, j.name, err) e.incrementJobCounter(j, Fail) @@ -379,6 +412,25 @@ func (e *executor) runJob(j internalJob, jIn jobIn) { } } +func (e *executor) callJobWithRecover(j internalJob) (err error) { + defer func() { + if recoverData := recover(); recoverData != nil { + _ = callJobFuncWithParams(j.afterJobRunsWithPanic, j.id, j.name, recoverData) + + // if panic is occurred, we should return an error + err = fmt.Errorf("%w from %v", ErrPanicRecovered, recoverData) + } + }() + + return callJobFuncWithParams(j.function, j.parameters...) +} + +func (e *executor) recordJobTiming(start time.Time, end time.Time, j internalJob) { + if e.monitor != nil { + e.monitor.RecordJobTiming(start, end, j.id, j.name, j.tags) + } +} + func (e *executor) incrementJobCounter(j internalJob, status JobStatus) { if e.monitor != nil { e.monitor.IncrementJob(j.id, j.name, j.tags, status) @@ -464,4 +516,8 @@ func (e *executor) stop(standardJobsWg, singletonJobsWg, limitModeJobsWg *waitGr e.logger.Debug("gocron: executor stopped") } waiterCancel() + + if e.limitMode != nil { + e.limitMode.started = false + } } diff --git a/vendor/github.com/go-co-op/gocron/v2/job.go b/vendor/github.com/go-co-op/gocron/v2/job.go index 4a7b8cff6..890889eda 100644 --- a/vendor/github.com/go-co-op/gocron/v2/job.go +++ b/vendor/github.com/go-co-op/gocron/v2/job.go @@ -38,10 +38,13 @@ type internalJob struct { limitRunsTo *limitRunsTo startTime time.Time startImmediately bool + stopTime time.Time // event listeners afterJobRuns func(jobID uuid.UUID, jobName string) beforeJobRuns func(jobID uuid.UUID, jobName string) afterJobRunsWithError func(jobID uuid.UUID, jobName string, err error) + afterJobRunsWithPanic func(jobID uuid.UUID, jobName string, recoverData any) + afterLockError func(jobID uuid.UUID, jobName string, err error) locker Locker } @@ -58,6 +61,13 @@ func (j *internalJob) stop() { j.cancel() } +func (j *internalJob) stopTimeReached(now time.Time) bool { + if j.stopTime.IsZero() { + return false + } + return j.stopTime.Before(now) +} + // task stores the function and parameters // that are actually run when the job is executed. type task struct { @@ -96,7 +106,7 @@ type limitRunsTo struct { // JobDefinition defines the interface that must be // implemented to create a job from the definition. type JobDefinition interface { - setup(*internalJob, *time.Location) error + setup(j *internalJob, l *time.Location, now time.Time) error } var _ JobDefinition = (*cronJobDefinition)(nil) @@ -106,7 +116,7 @@ type cronJobDefinition struct { withSeconds bool } -func (c cronJobDefinition) setup(j *internalJob, location *time.Location) error { +func (c cronJobDefinition) setup(j *internalJob, location *time.Location, now time.Time) error { var withLocation string if strings.HasPrefix(c.crontab, "TZ=") || strings.HasPrefix(c.crontab, "CRON_TZ=") { withLocation = c.crontab @@ -122,7 +132,7 @@ func (c cronJobDefinition) setup(j *internalJob, location *time.Location) error ) if c.withSeconds { - p := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + p := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) cronSchedule, err = p.Parse(withLocation) } else { cronSchedule, err = cron.ParseStandard(withLocation) @@ -130,6 +140,9 @@ func (c cronJobDefinition) setup(j *internalJob, location *time.Location) error if err != nil { return errors.Join(ErrCronJobParse, err) } + if cronSchedule.Next(now).IsZero() { + return ErrCronJobInvalid + } j.jobSchedule = &cronJob{cronSchedule: cronSchedule} return nil @@ -154,7 +167,7 @@ type durationJobDefinition struct { duration time.Duration } -func (d durationJobDefinition) setup(j *internalJob, _ *time.Location) error { +func (d durationJobDefinition) setup(j *internalJob, _ *time.Location, _ time.Time) error { if d.duration == 0 { return ErrDurationJobIntervalZero } @@ -176,7 +189,7 @@ type durationRandomJobDefinition struct { min, max time.Duration } -func (d durationRandomJobDefinition) setup(j *internalJob, _ *time.Location) error { +func (d durationRandomJobDefinition) setup(j *internalJob, _ *time.Location, _ time.Time) error { if d.min >= d.max { return ErrDurationRandomJobMinMax } @@ -226,7 +239,7 @@ type dailyJobDefinition struct { atTimes AtTimes } -func (d dailyJobDefinition) setup(j *internalJob, location *time.Location) error { +func (d dailyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error { atTimesDate, err := convertAtTimesToDateTime(d.atTimes, location) switch { case errors.Is(err, errAtTimesNil): @@ -239,6 +252,10 @@ func (d dailyJobDefinition) setup(j *internalJob, location *time.Location) error return ErrDailyJobMinutesSeconds } + if d.interval == 0 { + return ErrDailyJobZeroInterval + } + ds := dailyJob{ interval: d.interval, atTimes: atTimesDate, @@ -255,8 +272,11 @@ type weeklyJobDefinition struct { atTimes AtTimes } -func (w weeklyJobDefinition) setup(j *internalJob, location *time.Location) error { +func (w weeklyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error { var ws weeklyJob + if w.interval == 0 { + return ErrWeeklyJobZeroInterval + } ws.interval = w.interval if w.daysOfTheWeek == nil { @@ -320,8 +340,11 @@ type monthlyJobDefinition struct { atTimes AtTimes } -func (m monthlyJobDefinition) setup(j *internalJob, location *time.Location) error { +func (m monthlyJobDefinition) setup(j *internalJob, location *time.Location, _ time.Time) error { var ms monthlyJob + if m.interval == 0 { + return ErrMonthlyJobZeroInterval + } ms.interval = m.interval if m.daysOfTheMonth == nil { @@ -443,31 +466,58 @@ type oneTimeJobDefinition struct { startAt OneTimeJobStartAtOption } -func (o oneTimeJobDefinition) setup(j *internalJob, _ *time.Location) error { - j.jobSchedule = oneTimeJob{} - return o.startAt(j) +func (o oneTimeJobDefinition) setup(j *internalJob, _ *time.Location, now time.Time) error { + sortedTimes := o.startAt(j) + slices.SortStableFunc(sortedTimes, ascendingTime) + // deduplicate the times + sortedTimes = removeSliceDuplicatesTimeOnSortedSlice(sortedTimes) + // keep only schedules that are in the future + idx, found := slices.BinarySearchFunc(sortedTimes, now, ascendingTime) + if found { + idx++ + } + sortedTimes = sortedTimes[idx:] + if !j.startImmediately && len(sortedTimes) == 0 { + return ErrOneTimeJobStartDateTimePast + } + j.jobSchedule = oneTimeJob{sortedTimes: sortedTimes} + return nil +} + +func removeSliceDuplicatesTimeOnSortedSlice(times []time.Time) []time.Time { + ret := make([]time.Time, 0, len(times)) + for i, t := range times { + if i == 0 || t != times[i-1] { + ret = append(ret, t) + } + } + return ret } // OneTimeJobStartAtOption defines when the one time job is run -type OneTimeJobStartAtOption func(*internalJob) error +type OneTimeJobStartAtOption func(*internalJob) []time.Time // OneTimeJobStartImmediately tells the scheduler to run the one time job immediately. func OneTimeJobStartImmediately() OneTimeJobStartAtOption { - return func(j *internalJob) error { + return func(j *internalJob) []time.Time { j.startImmediately = true - return nil + return []time.Time{} } } // OneTimeJobStartDateTime sets the date & time at which the job should run. -// This datetime must be in the future. +// This datetime must be in the future (according to the scheduler clock). func OneTimeJobStartDateTime(start time.Time) OneTimeJobStartAtOption { - return func(j *internalJob) error { - if start.IsZero() || start.Before(time.Now()) { - return ErrOneTimeJobStartDateTimePast - } - j.startTime = start - return nil + return func(_ *internalJob) []time.Time { + return []time.Time{start} + } +} + +// OneTimeJobStartDateTimes sets the date & times at which the job should run. +// At least one of the date/times must be in the future (according to the scheduler clock). +func OneTimeJobStartDateTimes(times ...time.Time) OneTimeJobStartAtOption { + return func(_ *internalJob) []time.Time { + return times } } @@ -486,13 +536,13 @@ func OneTimeJob(startAt OneTimeJobStartAtOption) JobDefinition { // ----------------------------------------------- // JobOption defines the constructor for job options. -type JobOption func(*internalJob) error +type JobOption func(*internalJob, time.Time) error // WithDistributedJobLocker sets the locker to be used by multiple // Scheduler instances to ensure that only one instance of each // job is run. func WithDistributedJobLocker(locker Locker) JobOption { - return func(j *internalJob) error { + return func(j *internalJob, _ time.Time) error { if locker == nil { return ErrWithDistributedJobLockerNil } @@ -504,7 +554,7 @@ func WithDistributedJobLocker(locker Locker) JobOption { // WithEventListeners sets the event listeners that should be // run for the job. func WithEventListeners(eventListeners ...EventListener) JobOption { - return func(j *internalJob) error { + return func(j *internalJob, _ time.Time) error { for _, eventListener := range eventListeners { if err := eventListener(j); err != nil { return err @@ -517,7 +567,7 @@ func WithEventListeners(eventListeners ...EventListener) JobOption { // WithLimitedRuns limits the number of executions of this job to n. // Upon reaching the limit, the job is removed from the scheduler. func WithLimitedRuns(limit uint) JobOption { - return func(j *internalJob) error { + return func(j *internalJob, _ time.Time) error { j.limitRunsTo = &limitRunsTo{ limit: limit, runCount: 0, @@ -529,8 +579,7 @@ func WithLimitedRuns(limit uint) JobOption { // WithName sets the name of the job. Name provides // a human-readable identifier for the job. func WithName(name string) JobOption { - // TODO use the name for metrics and future logging option - return func(j *internalJob) error { + return func(j *internalJob, _ time.Time) error { if name == "" { return ErrWithNameEmpty } @@ -543,7 +592,7 @@ func WithName(name string) JobOption { // This is useful for jobs that should not overlap, and that occasionally // (but not consistently) run longer than the interval between job runs. func WithSingletonMode(mode LimitMode) JobOption { - return func(j *internalJob) error { + return func(j *internalJob, _ time.Time) error { j.singletonMode = true j.singletonLimitMode = mode return nil @@ -553,19 +602,19 @@ func WithSingletonMode(mode LimitMode) JobOption { // WithStartAt sets the option for starting the job at // a specific datetime. func WithStartAt(option StartAtOption) JobOption { - return func(j *internalJob) error { - return option(j) + return func(j *internalJob, now time.Time) error { + return option(j, now) } } // StartAtOption defines options for starting the job -type StartAtOption func(*internalJob) error +type StartAtOption func(*internalJob, time.Time) error // WithStartImmediately tells the scheduler to run the job immediately // regardless of the type or schedule of job. After this immediate run // the job is scheduled from this time based on the job definition. func WithStartImmediately() StartAtOption { - return func(j *internalJob) error { + return func(j *internalJob, _ time.Time) error { j.startImmediately = true return nil } @@ -574,25 +623,69 @@ func WithStartImmediately() StartAtOption { // WithStartDateTime sets the first date & time at which the job should run. // This datetime must be in the future. func WithStartDateTime(start time.Time) StartAtOption { - return func(j *internalJob) error { - if start.IsZero() || start.Before(time.Now()) { + return func(j *internalJob, now time.Time) error { + if start.IsZero() || start.Before(now) { return ErrWithStartDateTimePast } + if !j.stopTime.IsZero() && j.stopTime.Before(start) { + return ErrStartTimeLaterThanEndTime + } j.startTime = start return nil } } +// WithStopAt sets the option for stopping the job from running +// after the specified time. +func WithStopAt(option StopAtOption) JobOption { + return func(j *internalJob, now time.Time) error { + return option(j, now) + } +} + +// StopAtOption defines options for stopping the job +type StopAtOption func(*internalJob, time.Time) error + +// WithStopDateTime sets the final date & time after which the job should stop. +// This must be in the future and should be after the startTime (if specified). +// The job's final run may be at the stop time, but not after. +func WithStopDateTime(end time.Time) StopAtOption { + return func(j *internalJob, now time.Time) error { + if end.IsZero() || end.Before(now) { + return ErrWithStopDateTimePast + } + if end.Before(j.startTime) { + return ErrStopTimeEarlierThanStartTime + } + j.stopTime = end + return nil + } +} + // WithTags sets the tags for the job. Tags provide // a way to identify jobs by a set of tags and remove // multiple jobs by tag. func WithTags(tags ...string) JobOption { - return func(j *internalJob) error { + return func(j *internalJob, _ time.Time) error { j.tags = tags return nil } } +// WithIdentifier sets the identifier for the job. The identifier +// is used to uniquely identify the job and is used for logging +// and metrics. +func WithIdentifier(id uuid.UUID) JobOption { + return func(j *internalJob, _ time.Time) error { + if id == uuid.Nil { + return ErrWithIdentifierNil + } + + j.id = id + return nil + } +} + // ----------------------------------------------- // ----------------------------------------------- // ------------- Job Event Listeners ------------- @@ -603,6 +696,18 @@ func WithTags(tags ...string) JobOption { // listeners that can be used to listen for job events. type EventListener func(*internalJob) error +// BeforeJobRuns is used to listen for when a job is about to run and +// then run the provided function. +func BeforeJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) EventListener { + return func(j *internalJob) error { + if eventListenerFunc == nil { + return ErrEventListenerFuncNil + } + j.beforeJobRuns = eventListenerFunc + return nil + } +} + // AfterJobRuns is used to listen for when a job has run // without an error, and then run the provided function. func AfterJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) EventListener { @@ -627,14 +732,26 @@ func AfterJobRunsWithError(eventListenerFunc func(jobID uuid.UUID, jobName strin } } -// BeforeJobRuns is used to listen for when a job is about to run and +// AfterJobRunsWithPanic is used to listen for when a job has run and +// returned panicked recover data, and then run the provided function. +func AfterJobRunsWithPanic(eventListenerFunc func(jobID uuid.UUID, jobName string, recoverData any)) EventListener { + return func(j *internalJob) error { + if eventListenerFunc == nil { + return ErrEventListenerFuncNil + } + j.afterJobRunsWithPanic = eventListenerFunc + return nil + } +} + +// AfterLockError is used to when the distributed locker returns an error and // then run the provided function. -func BeforeJobRuns(eventListenerFunc func(jobID uuid.UUID, jobName string)) EventListener { +func AfterLockError(eventListenerFunc func(jobID uuid.UUID, jobName string, err error)) EventListener { return func(j *internalJob) error { if eventListenerFunc == nil { return ErrEventListenerFuncNil } - j.beforeJobRuns = eventListenerFunc + j.afterLockError = eventListenerFunc return nil } } @@ -845,10 +962,33 @@ func (m monthlyJob) nextMonthDayAtTime(lastRun time.Time, days []int, firstPass var _ jobSchedule = (*oneTimeJob)(nil) -type oneTimeJob struct{} +type oneTimeJob struct { + sortedTimes []time.Time +} -func (o oneTimeJob) next(_ time.Time) time.Time { - return time.Time{} +// next finds the next item in a sorted list of times using binary-search. +// +// example: sortedTimes: [2, 4, 6, 8] +// +// lastRun: 1 => [idx=0,found=false] => next is 2 - sorted[idx] idx=0 +// lastRun: 2 => [idx=0,found=true] => next is 4 - sorted[idx+1] idx=1 +// lastRun: 3 => [idx=1,found=false] => next is 4 - sorted[idx] idx=1 +// lastRun: 4 => [idx=1,found=true] => next is 6 - sorted[idx+1] idx=2 +// lastRun: 7 => [idx=3,found=false] => next is 8 - sorted[idx] idx=3 +// lastRun: 8 => [idx=3,found=found] => next is none +// lastRun: 9 => [idx=3,found=found] => next is none +func (o oneTimeJob) next(lastRun time.Time) time.Time { + idx, found := slices.BinarySearchFunc(o.sortedTimes, lastRun, ascendingTime) + // if found, the next run is the following index + if found { + idx++ + } + // exhausted runs + if idx >= len(o.sortedTimes) { + return time.Time{} + } + + return o.sortedTimes[idx] } // ----------------------------------------------- diff --git a/vendor/github.com/go-co-op/gocron/v2/monitor.go b/vendor/github.com/go-co-op/gocron/v2/monitor.go index ecf28805f..d3c5bbd9a 100644 --- a/vendor/github.com/go-co-op/gocron/v2/monitor.go +++ b/vendor/github.com/go-co-op/gocron/v2/monitor.go @@ -11,9 +11,10 @@ type JobStatus string // The different statuses of job that can be used. const ( - Fail JobStatus = "fail" - Success JobStatus = "success" - Skip JobStatus = "skip" + Fail JobStatus = "fail" + Success JobStatus = "success" + Skip JobStatus = "skip" + SingletonRescheduled JobStatus = "singleton_rescheduled" ) // Monitor represents the interface to collect jobs metrics. diff --git a/vendor/github.com/go-co-op/gocron/v2/scheduler.go b/vendor/github.com/go-co-op/gocron/v2/scheduler.go index 2cf976cc3..90ff52125 100644 --- a/vendor/github.com/go-co-op/gocron/v2/scheduler.go +++ b/vendor/github.com/go-co-op/gocron/v2/scheduler.go @@ -56,25 +56,43 @@ type Scheduler interface { // ----------------------------------------------- type scheduler struct { - shutdownCtx context.Context - shutdownCancel context.CancelFunc - exec executor - jobs map[uuid.UUID]internalJob - location *time.Location - clock clockwork.Clock - started bool + // context used for shutting down + shutdownCtx context.Context + // cancel used to signal scheduler should shut down + shutdownCancel context.CancelFunc + // the executor, which actually runs the jobs sent to it via the scheduler + exec executor + // the map of jobs registered in the scheduler + jobs map[uuid.UUID]internalJob + // the location used by the scheduler for scheduling when relevant + location *time.Location + // whether the scheduler has been started or not + started bool + // globally applied JobOption's set on all jobs added to the scheduler + // note: individually set JobOption's take precedence. globalJobOptions []JobOption - logger Logger - - startCh chan struct{} - startedCh chan struct{} - stopCh chan struct{} - stopErrCh chan error - allJobsOutRequest chan allJobsOutRequest - jobOutRequestCh chan jobOutRequest - runJobRequestCh chan runJobRequest - newJobCh chan newJobIn - removeJobCh chan uuid.UUID + // the scheduler's logger + logger Logger + + // used to tell the scheduler to start + startCh chan struct{} + // used to report that the scheduler has started + startedCh chan struct{} + // used to tell the scheduler to stop + stopCh chan struct{} + // used to report that the scheduler has stopped + stopErrCh chan error + // used to send all the jobs out when a request is made by the client + allJobsOutRequest chan allJobsOutRequest + // used to send a jobs out when a request is made by the client + jobOutRequestCh chan jobOutRequest + // used to run a job on-demand when requested by the client + runJobRequestCh chan runJobRequest + // new jobs are received here + newJobCh chan newJobIn + // requests from the client to remove jobs by ID are received here + removeJobCh chan uuid.UUID + // requests from the client to remove jobs by tags are received here removeJobsByTagsCh chan []string } @@ -111,6 +129,7 @@ func NewScheduler(options ...SchedulerOption) (Scheduler, error) { stopTimeout: time.Second * 10, singletonRunners: nil, logger: &noOpLogger{}, + clock: clockwork.NewRealClock(), jobsIn: make(chan jobIn), jobsOutForRescheduling: make(chan uuid.UUID), @@ -125,7 +144,6 @@ func NewScheduler(options ...SchedulerOption) (Scheduler, error) { exec: exec, jobs: make(map[uuid.UUID]internalJob), location: time.Local, - clock: clockwork.NewRealClock(), logger: &noOpLogger{}, newJobCh: make(chan newJobIn), @@ -293,7 +311,7 @@ func (s *scheduler) selectRemoveJob(id uuid.UUID) { } // Jobs coming back from the executor to the scheduler that -// need to evaluated for rescheduling. +// need to be evaluated for rescheduling. func (s *scheduler) selectExecJobsOutForRescheduling(id uuid.UUID) { select { case <-s.shutdownCtx.Done(): @@ -306,23 +324,31 @@ func (s *scheduler) selectExecJobsOutForRescheduling(id uuid.UUID) { // so we don't need to reschedule it. return } - var scheduleFrom time.Time + + if j.stopTimeReached(s.now()) { + return + } + + scheduleFrom := j.lastRun if len(j.nextScheduled) > 0 { // always grab the last element in the slice as that is the furthest // out in the future and the time from which we want to calculate // the subsequent next run time. - slices.SortStableFunc(j.nextScheduled, func(a, b time.Time) int { - return a.Compare(b) - }) + slices.SortStableFunc(j.nextScheduled, ascendingTime) scheduleFrom = j.nextScheduled[len(j.nextScheduled)-1] } + if scheduleFrom.IsZero() { + scheduleFrom = j.startTime + } + next := j.next(scheduleFrom) if next.IsZero() { // the job's next function will return zero for OneTime jobs. // since they are one time only, they do not need rescheduling. return } + if next.Before(s.now()) { // in some cases the next run time can be in the past, for example: // - the time on the machine was incorrect and has been synced with ntp @@ -333,8 +359,15 @@ func (s *scheduler) selectExecJobsOutForRescheduling(id uuid.UUID) { next = j.next(next) } } + + // Clean up any existing timer to prevent leaks + if j.timer != nil { + j.timer.Stop() + j.timer = nil // Ensure timer is cleared for GC + } + j.nextScheduled = append(j.nextScheduled, next) - j.timer = s.clock.AfterFunc(next.Sub(s.now()), func() { + j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() { // set the actual timer on the job here and listen for // shut down events so that the job doesn't attempt to // run if the scheduler has been shutdown. @@ -357,18 +390,16 @@ func (s *scheduler) selectExecJobsOutCompleted(id uuid.UUID) { return } - // if the job has more than one nextScheduled time, + // if the job has nextScheduled time in the past, // we need to remove any that are in the past. - if len(j.nextScheduled) > 1 { - var newNextScheduled []time.Time - for _, t := range j.nextScheduled { - if t.Before(s.now()) { - continue - } - newNextScheduled = append(newNextScheduled, t) + var newNextScheduled []time.Time + for _, t := range j.nextScheduled { + if t.Before(s.now()) { + continue } - j.nextScheduled = newNextScheduled + newNextScheduled = append(newNextScheduled, t) } + j.nextScheduled = newNextScheduled // if the job has a limited number of runs set, we need to // check how many runs have occurred and stop running this @@ -420,7 +451,7 @@ func (s *scheduler) selectNewJob(in newJobIn) { } id := j.id - j.timer = s.clock.AfterFunc(next.Sub(s.now()), func() { + j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() { select { case <-s.shutdownCtx.Done(): case s.exec.jobsIn <- jobIn{ @@ -430,6 +461,7 @@ func (s *scheduler) selectNewJob(in newJobIn) { } }) } + j.startTime = next j.nextScheduled = append(j.nextScheduled, next) } @@ -471,7 +503,7 @@ func (s *scheduler) selectStart() { } jobID := id - j.timer = s.clock.AfterFunc(next.Sub(s.now()), func() { + j.timer = s.exec.clock.AfterFunc(next.Sub(s.now()), func() { select { case <-s.shutdownCtx.Done(): case s.exec.jobsIn <- jobIn{ @@ -481,6 +513,7 @@ func (s *scheduler) selectStart() { } }) } + j.startTime = next j.nextScheduled = append(j.nextScheduled, next) s.jobs[id] = j } @@ -498,7 +531,7 @@ func (s *scheduler) selectStart() { // ----------------------------------------------- func (s *scheduler) now() time.Time { - return s.clock.Now().In(s.location) + return s.exec.clock.Now().In(s.location) } func (s *scheduler) jobFromInternalJob(in internalJob) job { @@ -531,6 +564,70 @@ func (s *scheduler) NewJob(jobDefinition JobDefinition, task Task, options ...Jo return s.addOrUpdateJob(uuid.Nil, jobDefinition, task, options) } +func (s *scheduler) verifyInterfaceVariadic(taskFunc reflect.Value, tsk task, variadicStart int) error { + ifaceType := taskFunc.Type().In(variadicStart).Elem() + for i := variadicStart; i < len(tsk.parameters); i++ { + if !reflect.TypeOf(tsk.parameters[i]).Implements(ifaceType) { + return ErrNewJobWrongTypeOfParameters + } + } + return nil +} + +func (s *scheduler) verifyVariadic(taskFunc reflect.Value, tsk task, variadicStart int) error { + if err := s.verifyNonVariadic(taskFunc, tsk, variadicStart); err != nil { + return err + } + parameterType := taskFunc.Type().In(variadicStart).Elem().Kind() + if parameterType == reflect.Interface { + return s.verifyInterfaceVariadic(taskFunc, tsk, variadicStart) + } + if parameterType == reflect.Pointer { + parameterType = reflect.Indirect(reflect.ValueOf(taskFunc.Type().In(variadicStart))).Kind() + } + + for i := variadicStart; i < len(tsk.parameters); i++ { + argumentType := reflect.TypeOf(tsk.parameters[i]).Kind() + if argumentType == reflect.Interface || argumentType == reflect.Pointer { + argumentType = reflect.TypeOf(tsk.parameters[i]).Elem().Kind() + } + if argumentType != parameterType { + return ErrNewJobWrongTypeOfParameters + } + } + return nil +} + +func (s *scheduler) verifyNonVariadic(taskFunc reflect.Value, tsk task, length int) error { + for i := 0; i < length; i++ { + t1 := reflect.TypeOf(tsk.parameters[i]).Kind() + if t1 == reflect.Interface || t1 == reflect.Pointer { + t1 = reflect.TypeOf(tsk.parameters[i]).Elem().Kind() + } + t2 := reflect.New(taskFunc.Type().In(i)).Elem().Kind() + if t2 == reflect.Interface || t2 == reflect.Pointer { + t2 = reflect.Indirect(reflect.ValueOf(taskFunc.Type().In(i))).Kind() + } + if t1 != t2 { + return ErrNewJobWrongTypeOfParameters + } + } + return nil +} + +func (s *scheduler) verifyParameterType(taskFunc reflect.Value, tsk task) error { + isVariadic := taskFunc.Type().IsVariadic() + if isVariadic { + variadicStart := taskFunc.Type().NumIn() - 1 + return s.verifyVariadic(taskFunc, tsk, variadicStart) + } + expectedParameterLength := taskFunc.Type().NumIn() + if len(tsk.parameters) != expectedParameterLength { + return ErrNewJobWrongNumberOfParameters + } + return s.verifyNonVariadic(taskFunc, tsk, expectedParameterLength) +} + func (s *scheduler) addOrUpdateJob(id uuid.UUID, definition JobDefinition, taskWrapper Task, options []JobOption) (Job, error) { j := internalJob{} if id == uuid.Nil { @@ -565,23 +662,8 @@ func (s *scheduler) addOrUpdateJob(id uuid.UUID, definition JobDefinition, taskW return nil, ErrNewJobTaskNotFunc } - expectedParameterLength := taskFunc.Type().NumIn() - if len(tsk.parameters) != expectedParameterLength { - return nil, ErrNewJobWrongNumberOfParameters - } - - for i := 0; i < expectedParameterLength; i++ { - t1 := reflect.TypeOf(tsk.parameters[i]).Kind() - if t1 == reflect.Interface || t1 == reflect.Pointer { - t1 = reflect.TypeOf(tsk.parameters[i]).Elem().Kind() - } - t2 := reflect.New(taskFunc.Type().In(i)).Elem().Kind() - if t2 == reflect.Interface || t2 == reflect.Pointer { - t2 = reflect.Indirect(reflect.ValueOf(taskFunc.Type().In(i))).Kind() - } - if t1 != t2 { - return nil, ErrNewJobWrongTypeOfParameters - } + if err := s.verifyParameterType(taskFunc, tsk); err != nil { + return nil, err } j.name = runtime.FuncForPC(taskFunc.Pointer()).Name() @@ -590,19 +672,19 @@ func (s *scheduler) addOrUpdateJob(id uuid.UUID, definition JobDefinition, taskW // apply global job options for _, option := range s.globalJobOptions { - if err := option(&j); err != nil { + if err := option(&j, s.now()); err != nil { return nil, err } } // apply job specific options, which take precedence for _, option := range options { - if err := option(&j); err != nil { + if err := option(&j, s.now()); err != nil { return nil, err } } - if err := definition.setup(&j, s.location); err != nil { + if err := definition.setup(&j, s.location, s.exec.clock.Now()); err != nil { return nil, err } @@ -621,13 +703,8 @@ func (s *scheduler) addOrUpdateJob(id uuid.UUID, definition JobDefinition, taskW case <-s.shutdownCtx.Done(): } - return &job{ - id: j.id, - name: j.name, - tags: slices.Clone(j.tags), - jobOutRequest: s.jobOutRequestCh, - runJobRequest: s.runJobRequestCh, - }, nil + out := s.jobFromInternalJob(j) + return &out, nil } func (s *scheduler) RemoveByTags(tags ...string) { @@ -710,7 +787,7 @@ func WithClock(clock clockwork.Clock) SchedulerOption { if clock == nil { return ErrWithClockNil } - s.clock = clock + s.exec.clock = clock return nil } } diff --git a/vendor/github.com/go-co-op/gocron/v2/util.go b/vendor/github.com/go-co-op/gocron/v2/util.go index 18986b363..a4e5b6fda 100644 --- a/vendor/github.com/go-co-op/gocron/v2/util.go +++ b/vendor/github.com/go-co-op/gocron/v2/util.go @@ -88,12 +88,14 @@ func convertAtTimesToDateTime(atTimes AtTimes, location *time.Location) ([]time. } atTimesDate = append(atTimesDate, at.time(location)) } - slices.SortStableFunc(atTimesDate, func(a, b time.Time) int { - return a.Compare(b) - }) + slices.SortStableFunc(atTimesDate, ascendingTime) return atTimesDate, nil } +func ascendingTime(a, b time.Time) int { + return a.Compare(b) +} + type waitGroupWithMutex struct { wg sync.WaitGroup mu sync.Mutex diff --git a/vendor/golang.org/x/exp/slices/sort.go b/vendor/golang.org/x/exp/slices/sort.go index b67897f76..f58bbc7ba 100644 --- a/vendor/golang.org/x/exp/slices/sort.go +++ b/vendor/golang.org/x/exp/slices/sort.go @@ -22,10 +22,12 @@ func Sort[S ~[]E, E constraints.Ordered](x S) { // SortFunc sorts the slice x in ascending order as determined by the cmp // function. This sort is not guaranteed to be stable. // cmp(a, b) should return a negative number when a < b, a positive number when -// a > b and zero when a == b. +// a > b and zero when a == b or when a is not comparable to b in the sense +// of the formal definition of Strict Weak Ordering. // // SortFunc requires that cmp is a strict weak ordering. // See https://en.wikipedia.org/wiki/Weak_ordering#Strict_weak_orderings. +// To indicate 'uncomparable', return 0 from the function. func SortFunc[S ~[]E, E any](x S, cmp func(a, b E) int) { n := len(x) pdqsortCmpFunc(x, 0, n, bits.Len(uint(n)), cmp) diff --git a/vendor/modules.txt b/vendor/modules.txt index 4f31897a5..99f0fbb12 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -31,7 +31,7 @@ github.com/evanphx/json-patch ## explicit; go 1.17 github.com/fsnotify/fsnotify github.com/fsnotify/fsnotify/internal -# github.com/go-co-op/gocron/v2 v2.5.0 +# github.com/go-co-op/gocron/v2 v2.12.4 ## explicit; go 1.20 github.com/go-co-op/gocron/v2 # github.com/go-logr/logr v1.4.2 @@ -178,7 +178,7 @@ github.com/spf13/pflag # github.com/vishvananda/netns v0.0.4 ## explicit; go 1.17 github.com/vishvananda/netns -# golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f +# golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/maps