Skip to content

Schedule Patterns

PatternUse Case
IntervalScheduleEvery N seconds/minutes/hours/days from scheduler start
HourlyScheduleEvery hour at specific minute
DailyScheduleEvery day at specific time
WeeklyScheduleSpecific days at specific time
MonthlyScheduleSpecific day of month at specific time
CronScheduleFull 5-field cron-style expressivity, typed

Run repeatedly at a fixed interval.

from horsies.core.models.schedule import IntervalSchedule
# Every 30 seconds
IntervalSchedule(seconds=30)
# Every 5 minutes
IntervalSchedule(minutes=5)
# Every 2 hours
IntervalSchedule(hours=2)
# Every day
IntervalSchedule(days=1)
# Combined: every 1 hour 30 minutes
IntervalSchedule(hours=1, minutes=30)

At least one time unit must be specified. Values are added together.

Run every hour at a specific minute (and optionally second).

from horsies.core.models.schedule import HourlySchedule
# Every hour at XX:00:00
HourlySchedule(minute=0)
# Every hour at XX:30:00
HourlySchedule(minute=30)
# Every hour at XX:15:45
HourlySchedule(minute=15, second=45)

Fields:

FieldRangeDefault
minute0-59required
second0-590

Run every day at a specific time.

from datetime import time
from horsies.core.models.schedule import DailySchedule
# Every day at 3:00 AM
DailySchedule(time=time(3, 0, 0))
# Every day at 6:30 PM
DailySchedule(time=time(18, 30, 0))
# Every day at midnight
DailySchedule(time=time(0, 0, 0))

The time field is a Python datetime.time object.

Run on specific days of the week at a specific time.

from datetime import time
from horsies.core.models.schedule import WeeklySchedule, Weekday
# Monday and Friday at 9 AM
WeeklySchedule(
days=[Weekday.MONDAY, Weekday.FRIDAY],
time=time(9, 0, 0),
)
# Every weekday at 8 AM
WeeklySchedule(
days=[
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
],
time=time(8, 0, 0),
)
# Weekends at noon
WeeklySchedule(
days=[Weekday.SATURDAY, Weekday.SUNDAY],
time=time(12, 0, 0),
)

Weekday values:

  • Weekday.MONDAY
  • Weekday.TUESDAY
  • Weekday.WEDNESDAY
  • Weekday.THURSDAY
  • Weekday.FRIDAY
  • Weekday.SATURDAY
  • Weekday.SUNDAY

Run on a specific day of the month at a specific time.

from datetime import time
from horsies.core.models.schedule import MonthlySchedule
# 1st of month at midnight
MonthlySchedule(day=1, time=time(0, 0, 0))
# 15th of month at noon
MonthlySchedule(day=15, time=time(12, 0, 0))
# Last valid day handling
MonthlySchedule(day=31, time=time(23, 59, 0))
# Months with fewer than 31 days are skipped (e.g. February, April)
FieldRangeDescription
day1-31Day of month
timetimeTime of day

Express anything a 5-field crontab (minute hour day-of-month month day-of-week) can express, through typed fields instead of a cron string. Fires at second :00 (minute granularity).

A CronSchedule has four parts:

FieldTypeDomain
minutelist[CronNumericTerm]0-59
hourlist[CronNumericTerm]0-23
monthlist[CronMonthTerm]Month.JANUARY-Month.DECEMBER
dayDaySelectorday-of-month and/or day-of-week

Each field holds a list of terms; the field matches the union of all its terms.

Numeric terms (minute, hour, day-of-month):

TermCronMeaning
CronEvery()*Every value in the domain
CronStep(step=n)*/nEvery nth value, anchored at the domain start
CronValues(values=[...])a,b,cAn explicit set
CronRange(start=a, end=b, step=s)a-b / a-b/sAn inclusive range

Enum terms (month, day-of-week), parameterized by Month or Weekday:

TermCronMeaning
CronEvery()*Every value
CronEnumStep[T](step=n)*/nEvery nth, by canonical order
CronEnumValues[T](values=[...])a,b,cAn explicit set
CronEnumRange[T](start=a, end=b, step=s)a-bAn inclusive range

CronStep is anchored at the domain start. CronStep(step=4) on hour matches 0, 4, 8, 12, 16, 20 — aligned to the clock, not to scheduler start.

This is what CronSchedule provides that IntervalSchedule cannot. IntervalSchedule(hours=4) runs every 4 hours measured from scheduler start, so it drifts and cannot stagger. CronSchedule aligns to the clock and lets a minute offset spread load:

from horsies.core.models.schedule import (
CronSchedule,
CronValues,
CronStep,
CronEvery,
EveryDay,
)
# Every 4 hours at minute 15: 00:15, 04:15, 08:15, 12:15, 16:15, 20:15
CronSchedule(
minute=[CronValues(values=[15])],
hour=[CronStep(step=4)],
month=[CronEvery()],
day=EveryDay(),
)

The minute offset (15) staggers this job away from jobs anchored at :00, avoiding synchronized bursts — impossible to express with an anchored interval.

Day-of-month and day-of-week are combined into one explicit DaySelector, removing cron’s silent “OR when both are set” ambiguity:

SelectorMeaning
EveryDay()Every day
ByMonthDay(day_of_month=[...])Match day-of-month only
ByWeekday(day_of_week=[...])Match day-of-week only
EitherDay(day_of_month=[...], day_of_week=[...])Match either (OR)
BothDays(day_of_month=[...], day_of_week=[...])Match both (AND)
from horsies.core.models.schedule import (
CronSchedule,
CronValues,
CronEvery,
CronEnumValues,
CronEnumRange,
ByWeekday,
EitherDay,
BothDays,
Weekday,
)
# Weekdays at 09:00 (Monday-Friday)
CronSchedule(
minute=[CronValues(values=[0])],
hour=[CronValues(values=[9])],
month=[CronEvery()],
day=ByWeekday(
day_of_week=[CronEnumRange[Weekday](start=Weekday.MONDAY, end=Weekday.FRIDAY)],
),
)
# The 13th OR any Friday, at midnight (whichever comes first)
CronSchedule(
minute=[CronValues(values=[0])],
hour=[CronValues(values=[0])],
month=[CronEvery()],
day=EitherDay(
day_of_month=[CronValues(values=[13])],
day_of_week=[CronEnumValues[Weekday](values=[Weekday.FRIDAY])],
),
)
# Friday the 13th only, at midnight (day-of-month AND weekday)
CronSchedule(
minute=[CronValues(values=[0])],
hour=[CronValues(values=[0])],
month=[CronEvery()],
day=BothDays(
day_of_month=[CronValues(values=[13])],
day_of_week=[CronEnumValues[Weekday](values=[Weekday.FRIDAY])],
),
)
CronCronSchedule
0 */4 * * *minute=[CronValues(values=[0])], hour=[CronStep(step=4)], month=[CronEvery()], day=EveryDay()
15 */4 * * *minute=[CronValues(values=[15])], hour=[CronStep(step=4)], ...
0 9 * * 1-5... hour=[CronValues(values=[9])], day=ByWeekday(day_of_week=[CronEnumRange[Weekday](start=Weekday.MONDAY, end=Weekday.FRIDAY)])
0 0 13 * 5... day=EitherDay(day_of_month=[CronValues(values=[13])], day_of_week=[CronEnumValues[Weekday](values=[Weekday.FRIDAY])])
0 0 1 1-3 *... month=[CronEnumRange[Month](start=Month.JANUARY, end=Month.MARCH)], day=ByMonthDay(day_of_month=[CronValues(values=[1])])

Note: standard cron ORs day-of-month and day-of-week when both are set (0 0 13 * 5 means “13th or Friday”). CronSchedule requires choosing EitherDay or BothDays.

Field domains, range ordering, step span, and satisfiability are checked when the CronSchedule is constructed; each violation raises ConfigurationError.

Don’t use a wrap-around enum range — use explicit values. Ranges require start <= end in canonical order (Monday-Sunday, January-December).

# Wrong: wrap-around range -> ConfigurationError when the schedule is built
CronSchedule(
minute=[CronValues(values=[0])], hour=[CronValues(values=[0])], month=[CronEvery()],
day=ByWeekday(day_of_week=[
CronEnumRange[Weekday](start=Weekday.FRIDAY, end=Weekday.MONDAY),
]),
)
# Correct
CronSchedule(
minute=[CronValues(values=[0])], hour=[CronValues(values=[0])], month=[CronEvery()],
day=ByWeekday(day_of_week=[CronEnumValues[Weekday](values=[
Weekday.FRIDAY, Weekday.SATURDAY, Weekday.SUNDAY, Weekday.MONDAY,
])]),
)

Don’t use a step larger than the field span. A step above the span selects only the first value, so it is rejected. The span is max - min: 59 for minute, 23 for hour, 30 for day-of-month, 11 for month, 6 for day-of-week.

# Wrong: step 24 on hour collapses to {0} -> ConfigurationError
CronSchedule(
minute=[CronEvery()], hour=[CronStep(step=24)], month=[CronEvery()], day=EveryDay(),
)
# Correct: pick the values explicitly
CronSchedule(
minute=[CronEvery()], hour=[CronValues(values=[0])], month=[CronEvery()], day=EveryDay(),
)

Don’t construct an unsatisfiable month/day combination. It is rejected at construction, not silently skipped forever.

# Wrong: February never has a 30th -> ConfigurationError
CronSchedule(
minute=[CronEvery()], hour=[CronEvery()],
month=[CronEnumValues[Month](values=[Month.FEBRUARY])],
day=ByMonthDay(day_of_month=[CronValues(values=[30])]),
)

For timezone-local patterns (DailySchedule, WeeklySchedule, MonthlySchedule, CronSchedule, HourlySchedule with a non-UTC timezone):

  • Spring forward (nonexistent local time, e.g. 02:30 in America/New_York on the transition day): that day’s run is skipped — there is no matching wall-clock instant.
  • Fall back (ambiguous local time): only the first occurrence fires; the repeated hour’s second occurrence is skipped.

Schedule at times outside transition windows (or in UTC) when every run matters.

NeedPattern
Run every N minutes/hours from scheduler startIntervalSchedule
Run at specific minute each hourHourlySchedule
Run at same time every dayDailySchedule
Run on specific weekdaysWeeklySchedule
Run monthly on specific dateMonthlySchedule
Clock-aligned “every N hours”, staggered, or any cron expressionCronSchedule
from datetime import time
from horsies.core.models.schedule import (
ScheduleConfig,
TaskSchedule,
IntervalSchedule,
DailySchedule,
WeeklySchedule,
)
config = AppConfig(
broker=PostgresConfig(...),
schedule=ScheduleConfig(
enabled=True,
schedules=[
# Heartbeat every 30 seconds
TaskSchedule(
name="heartbeat",
task_name="send_heartbeat",
pattern=IntervalSchedule(seconds=30),
),
# Daily cleanup at 3 AM UTC
TaskSchedule(
name="daily-cleanup",
task_name="cleanup_old_data",
pattern=DailySchedule(time=time(3, 0, 0)),
timezone="UTC",
),
# Weekly report on Mondays at 9 AM Eastern
TaskSchedule(
name="weekly-report",
task_name="generate_weekly_report",
pattern=WeeklySchedule(
days=[Weekday.MONDAY],
time=time(9, 0, 0),
),
timezone="America/New_York",
),
],
),
)

Horsies expresses cron’s full power through typed fields — never a cron string. For common cases the calendar patterns are shortest; for full cron expressivity use CronSchedule:

CronHorsies
0 3 * * *DailySchedule(time=time(3, 0, 0))
0 9 * * 1WeeklySchedule(days=[Weekday.MONDAY], time=time(9, 0))
15 */4 * * *CronSchedule(minute=[CronValues(values=[15])], hour=[CronStep(step=4)], month=[CronEvery()], day=EveryDay())

Benefits:

  • Type checking at definition time
  • Clear validation errors at construction
  • IDE autocomplete
  • No string parsing bugs
  • Ambiguities (day-of-month vs day-of-week) are explicit, not silent