Cron Controller: Schedules, Catch-up and Concurrency
HardAI-assisted coding · AI-Assisted Feature Work
Cron Controller: Schedules, Catch-up and Concurrency
You've joined the platform-runtime team that owns cronctl, the small controller that launches every team's scheduled jobs (think of a CronJob controller). It has had a rough quarter: jobs fire on the wrong days, a restart launched hundreds of runs at once, and two product requests are queued behind the fixes. Everything is UTC unless a Part says otherwise, and time is counted in whole minutes.
Codebase
| File | What it does |
|---|---|
cronctl/api.py | run(operations) entry point and the Dispatcher that routes each operation |
cronctl/controller.py | Controller: creating jobs, tick, finishing runs, status reads |
cronctl/schedule.py | Next-fire search: when does an expression fire next? |
cronctl/expression.py | CronExpression: the parsed five fields and the per-minute matching rule |
cronctl/fields.py | Parsing one field (*/15, 1-30/7, MON-FRI, lists) into a set of values |
cronctl/options.py | Job policy and the optional per-job options object |
cronctl/jobs.py | JobRegistry: job naming rules, creation order, lookup |
cronctl/runs.py | RunLedger: launching runs, recording how they ended |
cronctl/models.py | Job and Run records, run-state constants |
cronctl/clock.py | Time parsing/formatting, calendar helpers and the controller's shared clock |
cronctl/config.py | Constants (lookahead years, missed-schedule cap, history defaults, limits) |
cronctl/errors.py | CronError and every error code |
tests/ | Existing unit and end-to-end tests (some of them currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh controller and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <code>" strings, never raised.
- Time strings look like
"2024-03-01T09:30": UTC, minute resolution, exactly that shape, a real calendar date, year 2000-2199. Anything else isinvalid time. Results use the same format (a result may fall after 2199). - An unknown operation name returns
"error: unknown operation". A wrong number of arguments (or an empty or non-list operation) returns"error: invalid arguments". Arguments of the wrong type produce the specific error named in the tables below. - Operations that carry a time (
create_job,tick,finish, and laterbulk_create) have it as their first argument. The rest of the operations are reads or pure calculations.
Cron expressions
An expression has five fields separated by whitespace (leading, trailing and repeated whitespace is ignored): minute hour day-of-month month day-of-week.
- Ranges: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12 or
JAN-DEC, day-of-week 0-7 orSUN-SAT(both 0 and 7 mean Sunday). Names are case-insensitive and allowed only in the month and day-of-week fields. - A field is a comma-separated list of items. An item is
*, a valuev, or a rangelo-hi(lo <= hi), optionally followed by/step(an integer >= 1) **only after*or a range** (5/15is invalid). Numbers are plain decimal digits (leading zeros allowed). Anything else (empty items, signs,?,L,#, values out of range) isinvalid expression. *means the field's whole range (day-of-week: 0-6). A step counts from the first value of its range:*/15in minutes is 0, 15, 30, 45;*/10in day-of-month is 1, 11, 21, 31;*/2in month is JAN, MAR, MAY, ...;1-30/7is 1, 8, 15, 22, 29;MON-FRI/2is MON, WED, FRI.- A minute matches when its minute, hour and month are in their sets and its day matches:
- A day field (day-of-month or day-of-week) is restricted exactly when its text does **not begin with
***. So*,*/1and*/2are unrestricted;5,1-31andMONare restricted. - If both day fields are restricted, the day matches when day-of-month matches or day-of-week matches (Vixie cron).
- Otherwise the day matches only when both match. An unrestricted field still applies its own set:
*/2in day-of-month means odd days.
- Next fire. The *next fire* of an expression after minute
ais the smallest matching minute strictly greater thana, provided its calendar year is at most8 +the calendar year ofa(8 years always reaches the next 29 February). If there is none, the expression *never fires* froma.
Part 1: Schedules fire on the wrong days (INC-4417, INC-4429)
Goal: make next_runs follow the cron rules above. Teams report nightly jobs running on the wrong days of the month and Sunday jobs that never run.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
next_runs | expr, after, count | list of the next count fire times after after, oldest first | invalid expression, invalid time, invalid count, never fires |
Rules
countmust be an integer from 1 to 50 (trueis not one), otherwiseinvalid count.- The first time is the next fire of
exprafterafter; each following time is the next fire after the previous one. - If the first search finds nothing the result is
error: never fires. If a later search finds nothing the list simply ends there (it may be shorter thancount).
Example
[["next_runs", "*/20 9-10 * * MON-FRI", "2024-03-01T10:15", 4],
["next_runs", "0 12 13 * FRI", "2024-03-01T00:00", 4],
["next_runs", "0 12 */2 * FRI", "2024-03-01T00:00", 4]]returns
[["2024-03-01T10:20", "2024-03-01T10:40", "2024-03-04T09:00", "2024-03-04T09:20"],
["2024-03-01T12:00", "2024-03-08T12:00", "2024-03-13T12:00", "2024-03-15T12:00"],
["2024-03-01T12:00", "2024-03-15T12:00", "2024-03-29T12:00", "2024-04-05T12:00"]]The second expression is "the 13th or any Friday" (both fields restricted). The third has an unrestricted day-of-month (*/2), so it means odd days that are also Fridays.
Part 2: Restart storm and concurrency policies (INC-4466)
Goal: after Tuesday's deploy the controller was down for six hours; when it came back, tick launched every missed run of every job (360 copies of each every-minute job). Make tick behave like a Kubernetes CronJob controller: at most one catch-up run per job, a concurrency policy per job, an optional starting deadline, and a safety cap on missed schedules.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
create_job | now, name, expr, policy[, options] | true | invalid time, clock went backwards, invalid name, job exists, invalid expression, invalid policy, invalid options, never fires |
tick | now | list of event strings | invalid time, clock went backwards |
finish | now, run_id, outcome | true | invalid time, clock went backwards, invalid outcome, run not found, run not active |
job_status | name | {"last_schedule", "next_fire", "active", "launched"} | job not found |
run | run_id | {"job", "scheduled_for", "launched_at", "state", "finished_at"} | run not found |
Rules
- The clock. The controller's *clock* is the latest valid time presented so far by any time-carrying operation. An operation whose time is earlier than the clock returns
clock went backwardsand does nothing; equal times are fine. An operation whose time passes both time checks moves the clock to its time even if it then fails for another reason. Operations rejected asunknown operationorinvalid argumentsnever reach the controller and do nothing at all. create_job.nameis 1-40 characters: lower-case letters and digits in groups separated by single hyphens (nightly-report), otherwiseinvalid name.policyis"allow","forbid"or"replace".optionsis optional (absent ornullmeans defaults); in Part 2 its only key isstarting_deadline, a positive integer number of minutes ornull(= no deadline). Any other key or value isinvalid options. The job's schedule times are the fire times strictly after the creation time. A job whose expression never fires from the creation time is rejected withnever fires.tick(now)looks at every job in creation order and decides, independently for each:- Let
Lbe the job'slast_schedule(the schedule time of the newest run it launched) or, if it has launched nothing yet, its creation time. The job's *missed schedules* are its schedule timestwithL < t <= now, and, if the job has astarting_deadline, alsot >= now - starting_deadline(inclusive). - No missed schedules: nothing happens for this job.
- More than 100 missed schedules (exactly 100 is fine): event
overflow <job>, and nothing else changes for that job (no run,last_scheduleunchanged). - Otherwise let
Tbe the most recent missed schedule; at most one run is launched, forT:
allow: launch.forbid: if the job has any running run, eventskip <job> for <T> (forbid)and nothing else changes (last_schedulestays where it was, so that time is still "missed" at the next tick, together with any newer ones). Otherwise launch.replace: every running run of the job is deleted first, in launch order, each with eventreplace <run_id>; then launch. Deleted runs no longer exist (run not found).
- Launching creates a run with id
<job>-<n>(n= number of runs this job has launched so far + 1), staterunning,scheduled_for = T,launched_at = now; setslast_schedule = T; eventlaunch <run_id> for <T>. The result is the list of events in the order they happened. finish:outcomemust be"succeeded"or"failed"(elseinvalid outcome, checked before the run is looked up). The run's state becomes the outcome andfinished_atthe operation's time. A run that is notrunninggivesrun not active.job_status:last_schedule(time ornull),next_fire(the job's next schedule time strictly after the current clock, ornullif there is none),active(ids of running runs, oldest launch first) andlaunched(total runs launched).run:finished_atisnullwhile running.
Example
[["create_job", "2024-03-01T00:00", "report", "30 9 * * *", "allow"],
["create_job", "2024-03-01T00:00", "sync", "*/10 * * * *", "forbid"],
["tick", "2024-03-01T00:45"],
["tick", "2024-03-01T01:05"],
["finish", "2024-03-01T01:06", "sync-1", "succeeded"],
["tick", "2024-03-01T01:06"],
["tick", "2024-03-01T09:30"],
["job_status", "sync"]]returns true, true, then ["launch sync-1 for 2024-03-01T00:40"] (only the latest of four missed times runs), ["skip sync for 2024-03-01T01:00 (forbid)"], true, ["launch sync-2 for 2024-03-01T01:00"] (the skipped time is still missed once sync-1 is done), ["launch report-1 for 2024-03-01T09:30", "skip sync for 2024-03-01T09:30 (forbid)"], and finally {"last_schedule": "2024-03-01T01:00", "next_fire": "2024-03-01T09:40", "active": ["sync-2"], "launched": 2}.
Part 3: Hung runs and run history (INC-4502)
Goal: runs that hang block forbid jobs forever, and finished runs pile up on the cluster. Add run timeouts and bounded history. Part 2 behaviour must not change for jobs that use neither.
New options (keys of the create_job options object): timeout (positive integer minutes or null, default none), keep_succeeded and keep_failed (integers >= 0; defaults in cronctl/config.py: 3 and 1). Unknown keys or invalid values are invalid options. New run state: timed_out.
| Operation | Arguments | Returns | Errors |
|---|---|---|---|
history | name | [[run_id, state, finished_at], ...] | job not found |
Rules
- Timeouts. A
runningrun with a job timeout times out as soon aslaunched_at + timeout <= clock. Its state becomestimed_outand itsfinished_atislaunched_at + timeout(the moment it expired, not the moment anyone noticed). This is applied at the start of every time-carrying operation, right after its clock check passes (including operations that go on to fail), before the operation does anything else. So afinishon an already-expired run isrun not active, and aforbidjob'stickthat is exactly at the expiry time sees no running run. - Retention. Finished runs fall into two classes per job: *succeeded* (
succeeded) and *failed* (failedandtimed_out). Whenever a run finishes, each class keeps only the newestkeep_*runs of that job, newest meaning greaterfinished_at, ties broken by the higher launch number. Dropped runs are gone (run not found). Running runs and replaced runs never count; launch numbers keep counting up (ids are never reused) andlaunchedis unaffected. historylists the retained finished runs of both classes, oldest first by(finished_at, launch number).
Example
[["create_job", "2024-03-01T00:00", "etl", "0 * * * *", "allow", {"timeout": 45, "keep_succeeded": 1}],
["tick", "2024-03-01T01:00"],
["tick", "2024-03-01T02:00"],
["finish", "2024-03-01T02:10", "etl-2", "succeeded"],
["tick", "2024-03-01T03:00"],
["finish", "2024-03-01T03:20", "etl-3", "succeeded"],
["history", "etl"],
["run", "etl-2"],
["finish", "2024-03-01T03:21", "etl-1", "failed"]]returns true, ["launch etl-1 for 2024-03-01T01:00"], ["launch etl-2 for 2024-03-01T02:00"] (etl-1 timed out at 01:45), true, ["launch etl-3 for 2024-03-01T03:00"], true (that drops etl-2), [["etl-1", "timed_out", "2024-03-01T01:45"], ["etl-3", "succeeded", "2024-03-01T03:20"]], "error: run not found", "error: run not active".
Part 4: Time zones and intervals (requirements change, PLAT-912)
Goal: finance wants "09:00 Berlin time" jobs, and the monitoring team wants @every interval jobs. Add both. All earlier behaviour (catch-up, the 100 cap, deadlines, policies, timeouts, retention) applies to the new kinds of schedule unchanged.
Rules
- Time zones. The
tzoption ofcreate_joband an optional fourth argument ofnext_runsis a fixed UTC offset"+HH:MM"/"-HH:MM": hours 00-14, minutes 00, 15, 30 or 45, magnitude at most 14:00 ("+14:00"is valid,"+14:15","+5:30"and"UTC"are not). Invalid:invalid optionsforcreate_jobandinvalid timezonefornext_runs(checked afterinvalid count, beforenever fires). The default is+00:00. - A cron expression is matched against local time = UTC + offset: the local minute, hour, day-of-month, month and weekday. There is no daylight-saving time. The 8-year rule of "next fire" uses the local calendar year of
after. All times going in and out stay UTC. - Intervals. An expression of the form
@every <N>mor@every <N>h(Na positive integer without sign or leading zeros; at most 525 600 minutes in total; one space; surrounding/repeated whitespace ignored) fires everyNminutes (N * 60forh) counted from an anchor: the schedule times areanchor + k * intervalfork = 1, 2, ...(never the anchor itself). A job's anchor is its creation time (not the time of any launch); fornext_runsthe anchor is itsafter. Any other text starting with@isinvalid expression. Interval schedules never run out, so they never givenever fires. - A
tztogether with an@everyexpression isinvalid options(invalid timezonefornext_runs).
Example
[["next_runs", "30 2 * * *", "2024-03-01T00:00", 2, "+05:30"],
["next_runs", "0 0 1 * *", "2024-03-01T00:00", 2, "-08:00"],
["next_runs", "@every 90m", "2024-03-01T10:07", 3]]returns
[["2024-03-01T21:00", "2024-03-02T21:00"],
["2024-03-01T08:00", "2024-04-01T08:00"],
["2024-03-01T11:37", "2024-03-01T13:07", "2024-03-01T14:37"]](Local midnight at -08:00 is 08:00 UTC; the +05:30 job runs at 21:00 UTC on the previous UTC day of each local 02:30.)
Part 5: Fleet import and long outages (stretch)
Goal: the platform team wants to import whole fleets of jobs from Helm manifests and survive multi-year outages in staging. Add bulk creation and a compact tick summary, and make the controller fast enough.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
bulk_create | now, prefix, count, exprs, policy[, options] | count | invalid time, clock went backwards, invalid count, invalid name, job exists, invalid expression, invalid policy, invalid options, never fires |
tick | now, "counts" | {"launched", "skipped", "replaced", "overflowed", "digest"} | as tick; any third argument other than "counts" is invalid arguments |
Rules
bulk_createcreates the jobs<prefix>-0...<prefix>-<count-1>(creation order = index order); jobiusesexprs[i % len(exprs)]. Samepolicyandoptionsfor all.countis an integer 1-5000.prefixand every generated name must be a valid job name;exprsmust be a non-empty list of valid expressions. Anything wrong anywhere means no job is created (all or nothing);job existsif any generated name is taken. All jobs get the same creation timenow.tickwith"counts"performs exactly the same tick, but returns the number oflaunch,skip,replaceandoverflowevents, plusdigest: the sum over this tick's launches ofminutes_since_1970(scheduled_for) * (job_creation_index + 1), modulo 1 000 000 007, wherejob_creation_indexis the job's 0-based position in creation order.- Hidden tests include: a few hundred jobs with yearly, monthly and leap-day schedules ticked over a decade; a fleet that was "down" for ten years with every-minute jobs; and many schedules that can never fire. The whole suite has a few seconds, and decades of minutes are far too many to step through one by one.
Example
[["bulk_create", "2024-01-01T00:00", "probe", 3, ["*/30 * * * *", "@every 45m"], "allow"],
["tick", "2024-01-01T01:00", "counts"]]returns 3 and {"launched": 3, "skipped": 0, "replaced": 0, "overflowed": 0, "digest": 170407050}. (probe-0 and probe-2 run for 01:00; probe-1 runs for 00:45.)
Notes
clock.py,jobs.pyanderrors.pyare known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later Parts must not break earlier ones; hidden tests re-check earlier behaviour with the newer features switched on.
- Parts 1-4 are a strong result. Part 5 is a stretch.
Example 1
- Input:
- [["next_runs","0 12 13 * FRI","2024-03-01T00:00",4],["next_runs","0 12 */2 * FRI","2024-03-01T00:00",4]]
- Output:
- [["2024-03-01T12:00","2024-03-08T12:00","2024-03-13T12:00","2024-03-15T12:00"],["2024-03-01T12:00","2024-03-15T12:00","2024-03-29T12:00","2024-04-05T12:00"]]
- Explanation
- Both day fields restricted means the 13th OR any Friday.
*/2is unrestricted, so the second expression needs an odd day AND a Friday.
Example 2
- Input:
- [["create_job","2024-03-01T00:00","sync","*/10 * * * *","forbid"],["tick","2024-03-01T00:45"],["tick","2024-03-01T01:05"],["finish","2024-03-01T01:06","sync-1","succeeded"],["tick","2024-03-01T01:06"]]
- Output:
- [true,["launch sync-1 for 2024-03-01T00:40"],["skip sync for 2024-03-01T01:00 (forbid)"],true,["launch sync-2 for 2024-03-01T01:00"]]
- Explanation
- After the outage only the latest missed time (00:40) runs. The forbid skip leaves the 01:00 schedule time missed, so it runs as soon as sync-1 is finished.
Example 3
- Input:
- [["next_runs","30 2 * * *","2024-03-01T00:00",2,"+05:30"],["next_runs","@every 90m","2024-03-01T10:07",3]]
- Output:
- [["2024-03-01T21:00","2024-03-02T21:00"],["2024-03-01T11:37","2024-03-01T13:07","2024-03-01T14:37"]]
- Explanation
- The cron fields are read in local time (UTC+05:30) and results are reported in UTC; an @every schedule counts from its anchor.
Guidelines
- Python 3 standard library only
- Time is whole minutes; wire timestamps are "YYYY-MM-DDTHH:MM" in UTC and results use the same format
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- A schedule that cannot fire within 8 calendar years of the search start "never fires"
- Part 5 inputs span decades of simulated time, hundreds of jobs and ten-year outages; the whole suite has a few seconds