Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for setting schedules with multiple values #24

Merged
merged 3 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Sources/Jobs/Scheduler/JobSchedule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ public struct JobSchedule: MutableCollection, Sendable {
/// A single scheduled Job
public struct Element: Sendable {
var nextScheduledDate: Date
let schedule: Schedule
var schedule: Schedule
let jobParameters: any JobParameters
let accuracy: ScheduleAccuracy

public init(job: JobParameters, schedule: Schedule, accuracy: ScheduleAccuracy = .latest) {
let nextScheduledDate = schedule.nextDate() ?? .distantFuture
var schedule = schedule
let nextScheduledDate = schedule.nextDate(after: .now) ?? .distantFuture
self.nextScheduledDate = nextScheduledDate
self.schedule = schedule
self.jobParameters = job
Expand Down Expand Up @@ -171,7 +172,7 @@ public struct JobSchedule: MutableCollection, Sendable {
do {
if let date = try await self.jobQueue.getMetadata(.jobScheduleLastDate) {
for index in 0..<jobSchedule.count {
jobSchedule[index].nextScheduledDate = jobSchedule[index].schedule.nextDate(after: date) ?? .distantFuture
jobSchedule[index].nextScheduledDate = jobSchedule[index].schedule.setInitialNextDate(after: date) ?? .distantFuture
}
}
} catch {
Expand Down
153 changes: 121 additions & 32 deletions Sources/Jobs/Scheduler/Schedule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//
//===----------------------------------------------------------------------===//

import Collections
#if os(Linux)
adam-fowler marked this conversation as resolved.
Show resolved Hide resolved
@preconcurrency import Foundation
#else
Expand All @@ -21,18 +22,22 @@ import Foundation
/// Generates a Date at regular intervals (hourly, daily, weekly etc)
public struct Schedule: Sendable {
/// Day of week
public enum Day: Int, Sendable {
public enum Day: Int, Sendable, Comparable {
case sunday = 1
case monday = 2
case tuesday = 3
case wednesday = 4
case thursday = 5
case friday = 6
case saturday = 7

public static func < (lhs: Schedule.Day, rhs: Schedule.Day) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

/// Month of the year
public enum Month: Int, Sendable {
public enum Month: Int, Sendable, Comparable {
case january = 1
case february = 2
case march = 3
Expand All @@ -45,21 +50,42 @@ public struct Schedule: Sendable {
case october = 10
case november = 11
case december = 12

public static func < (lhs: Schedule.Month, rhs: Schedule.Month) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

/// Schedule parameter
enum Parameter<Value: Sendable>: Sendable {
case any
case specific(Value)
case selection(Deque<Value>)

mutating func nextValue() -> Value? {
switch self {
case .specific(let value):
return value
case .selection(var values):
let second = values.popFirst()
if let second {
values.append(second)
self = .selection(values)
}
return second
case .any:
return nil
}
}
}

let second: Parameter<Int>
let minute: Parameter<Int>
let hour: Parameter<Int>
let date: Parameter<Int>
let month: Parameter<Month>
let day: Parameter<Day>
let calendar: Calendar
var second: Parameter<Int>
var minute: Parameter<Int>
var hour: Parameter<Int>
var date: Parameter<Int>
var month: Parameter<Month>
var day: Parameter<Day>
var calendar: Calendar

init(
second: Parameter<Int> = .any,
Expand Down Expand Up @@ -91,12 +117,29 @@ public struct Schedule: Sendable {
.init(second: .specific(second))
}

/// Return Schedule that generates a Date for a selection of minutes
/// - Parameters
/// - minutes: Array of minutes if should return Dates for
/// - second: Second value it should return a Date at
public static func onMinutes(_ minutes: some Sequence<Int>, second: Int = 0) -> Self {
.init(second: .specific(second), minute: .selection(Deque(minutes.sorted())))
}

/// Return a schedule that generates a Date for every hour
/// - Parameter minute: Minute value it should return the Date at
public static func hourly(minute: Int = 0) -> Self {
.init(minute: .specific(minute))
}

/// Return Schedule that generates a Date for a selection of hours
/// - Parameters:
/// - hours: Array of hours if should return Dates for
/// - minute: Minute value it should return a Date at
/// - timeZone: TimeZone to run schedule in
public static func onHours(_ hours: some Sequence<Int>, minute: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(minute: .specific(minute), hour: .selection(Deque(hours.sorted())), timeZone: timeZone)
}

/// Return a schedule that generates a Date once a day
/// - Parameters:
/// - hour: Hour value it should return Date at
Expand All @@ -106,54 +149,100 @@ public struct Schedule: Sendable {
.init(minute: .specific(minute), hour: .specific(hour), timeZone: timeZone)
}

/// Return Schedule that generates a Date for a selection of days of the week
/// - Parameters:
/// - days: Array of week days it should return Dates for
/// - hour: Hour it should return a Date at
/// - minute: Minute value it should return a Date at
/// - timeZone: TimeZone to run schedule in
public static func onDays(_ days: some Sequence<Day>, hour: Int = 0, minute: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(minute: .specific(minute), hour: .specific(hour), day: .selection(Deque(days.sorted())), timeZone: timeZone)
}

/// Return a schedule that generates a Date once a week
/// - Parameters:
/// - day: Day on which it should return Date at
/// - day: Week day on which it should return Date at
/// - hour: Hour value is should return Date at
/// - timeZone: Time zone to use when scheduling
public static func weekly(day: Day, hour: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(hour: .specific(hour), day: .specific(day), timeZone: timeZone)
public static func weekly(day: Day, hour: Int = 0, minute: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(minute: .specific(minute), hour: .specific(hour), day: .specific(day), timeZone: timeZone)
}

/// Return Schedule that generates a Date for a selection of month dates
/// - Parameters:
/// - dates: Array of dates of the month it should return Dates for
/// - hour: Hour it should return a Date at
/// - minute: Minute value it should return a Date at
/// - timeZone: TimeZone to run schedule in
/// - Returns:
public static func onDates(_ dates: some Sequence<Int>, hour: Int = 0, minute: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(minute: .specific(minute), hour: .specific(hour), date: .selection(Deque(dates.sorted())), timeZone: timeZone)
}

/// Return a schedule that generates a Date once a month
/// - Parameters:
/// - date: Date on which it should return Date at
/// - date: Day of month on which it should return Date at
/// - hour: Hour value is should return Date at
/// - minute: Minute value it should return a Date at
/// - timeZone: Time zone to use when scheduling
public static func monthly(date: Int, hour: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(hour: .specific(hour), date: .specific(date), timeZone: timeZone)
public static func monthly(date: Int, hour: Int = 0, minute: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(minute: .specific(minute), hour: .specific(hour), date: .specific(date), timeZone: timeZone)
}

/// Return Schedule that generates a Date for a selection of months
/// - Parameters:
/// - months: Array of months it should return Dates for
/// - date: Date it should return a Date at
/// - hour: Hour it should return a Date at
/// - minute: Minute value it should return a Date at
/// - timeZone: TimeZone to run schedule in
/// - Returns:
public static func onMonths(_ months: some Sequence<Month>, date: Int, hour: Int = 0, minute: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(minute: .specific(minute), hour: .specific(hour), date: .specific(date), month: .selection(Deque(months.sorted())), timeZone: timeZone)
}

/// Return a schedule that generates a Date once a year
/// - Parameters:
/// - month: Month on which it should return Date at
/// - date: Date on which it should return Date at
/// - hour: Hour value is should return Date at
/// - minute: Minute value it should return a Date at
/// - timeZone: Time zone to use when scheduling
public static func yearly(month: Month, date: Int, hour: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(hour: .specific(hour), date: .specific(date), month: .specific(month), timeZone: timeZone)
public static func yearly(month: Month, date: Int, hour: Int = 0, minute: Int = 0, timeZone: TimeZone = .current) -> Self {
.init(minute: .specific(minute), hour: .specific(hour), date: .specific(date), month: .specific(month), timeZone: timeZone)
}

/// Return next date in schedule after the supplied Date
/// - Parameter date: start date
public func nextDate(after date: Date = .now) -> Date? {
public mutating func nextDate(after date: Date) -> Date? {
var dateComponents = DateComponents()
dateComponents.nanosecond = 0
if case .specific(let second) = self.second {
dateComponents.second = second
}
if case .specific(let minute) = self.minute {
dateComponents.minute = minute
}
if case .specific(let hour) = self.hour {
dateComponents.hour = hour
}
if case .specific(let day) = self.day {
dateComponents.weekday = day.rawValue
dateComponents.second = self.second.nextValue()
dateComponents.minute = self.minute.nextValue()
dateComponents.hour = self.hour.nextValue()
dateComponents.weekday = self.day.nextValue()?.rawValue
dateComponents.day = self.date.nextValue()
dateComponents.month = self.month.nextValue()?.rawValue
return self.calendar.nextDate(after: date, matching: dateComponents, matchingPolicy: .strict)
}

/// Set up scheduler to return the correct next date, based on a supplied Date.
/// - Parameter date: start date
public mutating func setInitialNextDate(after date: Date) -> Date? {
guard var nextDate = self.nextDate(after: date) else {
return nil
}
if case .specific(let date) = self.date {
dateComponents.day = date
var prevDate = date
// Repeat while the nextDate is greater than the prevDate. At the point the nextDate is less than
// the previous date we know any schedules with multiple values have selected the correct next value
while prevDate < nextDate {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! I think this will fix a current bug I notice where a job might not run until the metadata table is truncated. I have open a ticket yet because I am trying to get a reproducible example.

prevDate = nextDate
guard let nextDateUnwrapped = self.nextDate(after: date) else {
return nil
}
nextDate = nextDateUnwrapped
}
return self.calendar.nextDate(after: date, matching: dateComponents, matchingPolicy: .strict)

return nextDate
}
}
61 changes: 60 additions & 1 deletion Tests/JobsTests/JobSchedulerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import XCTest

final class JobSchedulerTests: XCTestCase {
func testSchedule(start: String, expectedEnd: String, schedule: Schedule) throws {
var schedule = schedule
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
Expand All @@ -33,6 +34,29 @@ final class JobSchedulerTests: XCTestCase {
XCTAssertEqual(expectedEndDate, end)
}

func testInitMutatingSchedule(start: String, expectedEnd: String, schedule: inout Schedule) throws -> Date {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
dateFormatter.timeZone = schedule.calendar.timeZone
let startDate = try XCTUnwrap(dateFormatter.date(from: start))
let expectedEndDate = try XCTUnwrap(dateFormatter.date(from: expectedEnd))
let end = try XCTUnwrap(schedule.setInitialNextDate(after: startDate))
XCTAssertEqual(expectedEndDate, end)
return end
}

func testMutatingSchedule(date: Date, expectedEnd: String, schedule: inout Schedule) throws -> Date {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
dateFormatter.timeZone = schedule.calendar.timeZone
let expectedEndDate = try XCTUnwrap(dateFormatter.date(from: expectedEnd))
let end = try XCTUnwrap(schedule.nextDate(after: date))
XCTAssertEqual(expectedEndDate, end)
return end
}

func testMinuteSchedule() throws {
try self.testSchedule(start: "2021-06-21T21:10:15Z", expectedEnd: "2021-06-21T21:10:43Z", schedule: .everyMinute(second: 43))
try self.testSchedule(start: "1999-12-31T23:59:25Z", expectedEnd: "2000-01-01T00:00:15Z", schedule: .everyMinute(second: 15))
Expand Down Expand Up @@ -62,9 +86,44 @@ final class JobSchedulerTests: XCTestCase {
try self.testSchedule(start: "1999-12-31T23:59:25Z", expectedEnd: "2000-01-14T04:00:00Z", schedule: .monthly(date: 14, hour: 4))
}

func testMinutesSchedule() throws {
var schedule = Schedule.onMinutes([0, 15, 30, 45], second: 0)
let date = try self.testInitMutatingSchedule(start: "2021-06-21T21:10:16Z", expectedEnd: "2021-06-21T21:15:00Z", schedule: &schedule)
_ = try self.testMutatingSchedule(date: date, expectedEnd: "2021-06-21T21:30:00Z", schedule: &schedule)
}

func testHoursSchedule() throws {
var schedule = Schedule.onHours([8, 20], minute: 0)
var date = try self.testInitMutatingSchedule(start: "2021-06-21T21:10:16Z", expectedEnd: "2021-06-22T08:00:00Z", schedule: &schedule)
date = try self.testMutatingSchedule(date: date, expectedEnd: "2021-06-22T20:00:00Z", schedule: &schedule)
_ = try self.testMutatingSchedule(date: date, expectedEnd: "2021-06-23T08:00:00Z", schedule: &schedule)
}

func testDaysSchedule() throws {
var schedule = Schedule.onDays([.saturday, .sunday], hour: 4, minute: 0)
var date = try self.testInitMutatingSchedule(start: "2021-06-21T21:10:16Z", expectedEnd: "2021-06-26T04:00:00Z", schedule: &schedule)
date = try self.testMutatingSchedule(date: date, expectedEnd: "2021-06-27T04:00:00Z", schedule: &schedule)
_ = try self.testMutatingSchedule(date: date, expectedEnd: "2021-07-03T04:00:00Z", schedule: &schedule)
}

func testDatesSchedule() throws {
var schedule = Schedule.onDates([1, 2, 24], hour: 4, minute: 0)
var date = try self.testInitMutatingSchedule(start: "2021-06-21T21:10:16Z", expectedEnd: "2021-06-24T04:00:00Z", schedule: &schedule)
date = try self.testMutatingSchedule(date: date, expectedEnd: "2021-07-01T04:00:00Z", schedule: &schedule)
_ = try self.testMutatingSchedule(date: date, expectedEnd: "2021-07-02T04:00:00Z", schedule: &schedule)
}

func testMonthsSchedule() throws {
var schedule = Schedule.onMonths([.january, .july], date: 2, hour: 4, minute: 0)
var date = try self.testInitMutatingSchedule(start: "2021-06-21T21:10:16Z", expectedEnd: "2021-07-02T04:00:00Z", schedule: &schedule)
date = try self.testMutatingSchedule(date: date, expectedEnd: "2022-01-02T04:00:00Z", schedule: &schedule)
_ = try self.testMutatingSchedule(date: date, expectedEnd: "2022-07-02T04:00:00Z", schedule: &schedule)
}

func testScheduleTimeZone() throws {
let startDate = ISO8601DateFormatter().date(from: "2021-06-21T21:10:15Z")!
let scheduledDate = try XCTUnwrap(Schedule.daily(hour: 4, timeZone: .init(secondsFromGMT: 0)!).nextDate(after: startDate))
var schedule = Schedule.daily(hour: 4, timeZone: .init(secondsFromGMT: 0)!)
let scheduledDate = try XCTUnwrap(schedule.nextDate(after: startDate))
let dateComponents = Calendar.current.dateComponents([.hour], from: scheduledDate)
// check timezone difference is the same as the difference in the schedule
XCTAssertEqual(TimeZone.current.secondsFromGMT(), (dateComponents.hour! - 4) * 3600)
Expand Down
Loading